diff --git a/Cargo.lock b/Cargo.lock index 6b37bc2..96e1ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ dependencies = [ "devo-protocol", "devo-provider", "devo-safety", + "diffy", "filedescriptor", "futures", "glob", diff --git a/README.md b/README.md index 15ffda7..8e0c211 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ - [Installation](#-installation) - [Quick Start](#-quick-start) +- [Configuration](#%EF%B8%8F-configuration) - [FAQ](#-faq) - [Contributing](#-contributing) - [References](#-references) diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs index 448331e..edb6141 100644 --- a/crates/cli/src/agent_command.rs +++ b/crates/cli/src/agent_command.rs @@ -6,6 +6,7 @@ use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; use devo_core::ProviderConfigFile; use devo_core::ResolvedProviderSettings; +use devo_core::SessionId; use devo_core::load_config; use devo_core::project_config_key; use devo_core::resolve_provider_settings; @@ -23,7 +24,11 @@ use devo_utils::find_devo_home; /// when a provider config already exists. `log_level` is forwarded to the /// background server process, and `model_override` replaces the resolved model /// for this session without mutating the stored provider config. -pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) -> Result<()> { +pub(crate) async fn run_agent( + force_onboarding: bool, + log_level: Option<&str>, + initial_session_id: Option, +) -> Result { let cwd = std::env::current_dir()?; let config_home = find_devo_home().context("could not determine devo home directory")?; let model_catalog = PresetModelCatalog::load_from_config(&config_home, Some(&cwd))?; @@ -55,6 +60,7 @@ pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) - run_interactive_tui(InteractiveTuiConfig { // initial_session corresponding fields at top of `config.toml`. initial_session: InitialTuiSession { + session_id: initial_session_id, model, provider: wire_api, thinking_selection: model_thinking_selection, @@ -68,7 +74,6 @@ pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) - show_model_onboarding: onboarding_mode, }) .await - .map(|_| ()) } /// Resolves the initial provider settings and whether onboarding should be shown. diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9c17f58..f874462 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -8,6 +8,7 @@ use devo_core::AppConfigLoader; use devo_core::FileSystemAppConfigLoader; use devo_core::LoggingBootstrap; use devo_core::LoggingRuntime; +use devo_core::SessionId; use devo_core::UpdateCheckOutcome; use devo_core::UpdateChecker; use devo_core::format_update_notification; @@ -52,6 +53,86 @@ fn main() -> Result<()> { devo_arg0::run_as(|_paths| async { run_cli().await }) } +fn format_with_separators(value: usize) -> String { + let digits = value.to_string(); + let mut out = String::new(); + for (index, ch) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_token_usage_line(exit: &devo_tui::AppExit, color_enabled: bool) -> Option { + let total = exit.total_input_tokens + exit.total_output_tokens; + let non_cached_input = exit + .total_input_tokens + .saturating_sub(exit.total_cache_read_tokens); + if total == 0 && exit.total_cache_read_tokens == 0 { + return None; + } + let total_value = format_with_separators(total); + let input_value = format_with_separators(non_cached_input); + let output_value = format_with_separators(exit.total_output_tokens); + let cached_suffix = if exit.total_cache_read_tokens > 0 { + let cached_value = format_with_separators(exit.total_cache_read_tokens); + if color_enabled { + format!( + " (+ {} {})", + "\u{1b}[1;33m".to_string() + &cached_value + "\u{1b}[0m", + "\u{1b}[33mcached\u{1b}[0m" + ) + } else { + format!(" (+ {cached_value} cached)") + } + } else { + String::new() + }; + Some(format!( + "Token usage: total={} input={}{} output={}", + if color_enabled { + format!("\u{1b}[1;36m{total_value}\u{1b}[0m") + } else { + total_value + }, + if color_enabled { + format!("\u{1b}[1;32m{input_value}\u{1b}[0m") + } else { + input_value + }, + cached_suffix, + if color_enabled { + format!("\u{1b}[1;35m{output_value}\u{1b}[0m") + } else { + output_value + }, + )) +} + +fn exit_messages(exit: &devo_tui::AppExit, color_enabled: bool) -> Vec { + let mut lines = Vec::new(); + if let Some(line) = format_token_usage_line(exit, color_enabled) { + lines.push(line); + } + if let Some(session_id) = exit.session_id { + let command = format!("devo resume {session_id}"); + let command = if color_enabled { + format!("\u{1b}[1;36m{command}\u{1b}[0m") + } else { + command + }; + let prefix = if color_enabled { + "\u{1b}[2mTo continue this session, run\u{1b}[0m".to_string() + } else { + "To continue this session, run".to_string() + }; + lines.push(format!("{prefix} {command}")); + } + lines +} + async fn run_cli() -> Result<()> { let cli = Cli::parse(); let log_level = cli.log_level.map(|level| level.to_string()); @@ -62,7 +143,11 @@ async fn run_cli() -> Result<()> { // Resolve logging config early, install the process-wide file subscriber, // and keep its non-blocking writer guard alive for the command lifetime. let _logging = install_logging(&cli)?; - run_agent(/*force_onboarding*/ true, log_level.as_deref()).await + let exit = run_agent(/*force_onboarding*/ true, log_level.as_deref(), None).await?; + for line in exit_messages(&exit, /*color_enabled*/ true) { + println!("{line}"); + } + Ok(()) } Some(Command::Prompt { input }) => { maybe_print_startup_update(&cli).await; @@ -73,6 +158,20 @@ async fn run_cli() -> Result<()> { let _logging = install_logging(&cli)?; run_doctor().await } + Some(Command::Resume { session_id }) => { + maybe_print_startup_update(&cli).await; + let _logging = install_logging(&cli)?; + let exit = run_agent( + /*force_onboarding*/ false, + log_level.as_deref(), + Some(*session_id), + ) + .await?; + for line in exit_messages(&exit, /*color_enabled*/ true) { + println!("{line}"); + } + Ok(()) + } Some(Command::Server { working_root, transport, @@ -87,7 +186,11 @@ async fn run_cli() -> Result<()> { None => { maybe_print_startup_update(&cli).await; let _logging = install_logging(&cli)?; - run_agent(/*force_onboarding*/ false, log_level.as_deref()).await + let exit = run_agent(/*force_onboarding*/ false, log_level.as_deref(), None).await?; + for line in exit_messages(&exit, /*color_enabled*/ true) { + println!("{line}"); + } + Ok(()) } } } @@ -96,6 +199,11 @@ async fn run_cli() -> Result<()> { enum Command { /// Launch the interactive onboarding flow to configure a model provider. Onboard, + /// Resume a saved interactive session by id. + Resume { + /// Session identifier printed by Devo at exit time. + session_id: SessionId, + }, /// Send a single prompt to the model and print the response (non-interactive). Prompt { /// The prompt text to send to the model. @@ -193,12 +301,15 @@ fn cli_logging_overrides(cli: &Cli) -> toml::Value { #[cfg(test)] mod tests { use clap::Parser; + use devo_core::SessionId; use pretty_assertions::assert_eq; use tracing_subscriber::filter::LevelFilter; use super::Cli; use super::Command; use super::cli_logging_overrides; + use super::exit_messages; + use super::format_token_usage_line; #[test] fn cli_parses_supported_log_levels() { @@ -328,4 +439,56 @@ mod tests { false ); } + + #[test] + fn cli_parses_resume_subcommand() { + let session_id = SessionId::new(); + let cli = + Cli::try_parse_from(["devo", "resume", &session_id.to_string()]).expect("parse resume"); + + match cli.command { + Some(Command::Resume { session_id: actual }) => assert_eq!(actual, session_id), + other => panic!("expected resume command, got {other:?}"), + } + } + + #[test] + fn exit_messages_includes_usage_and_resume_hint() { + let session_id = SessionId::new(); + let exit = devo_tui::AppExit { + session_id: Some(session_id), + turn_count: 1, + total_input_tokens: 10, + total_output_tokens: 2, + total_cache_read_tokens: 5, + }; + + let lines = exit_messages(&exit, /*color_enabled*/ false); + assert_eq!( + lines[0], + "Token usage: total=12 input=5 (+ 5 cached) output=2" + ); + assert_eq!( + lines[1], + format!("To continue this session, run devo resume {session_id}") + ); + } + + #[test] + fn colorized_exit_messages_include_ansi_sequences() { + let session_id = SessionId::new(); + let exit = devo_tui::AppExit { + session_id: Some(session_id), + turn_count: 1, + total_input_tokens: 10, + total_output_tokens: 2, + total_cache_read_tokens: 5, + }; + + let usage = format_token_usage_line(&exit, /*color_enabled*/ true).expect("usage line"); + assert!(usage.contains("\u{1b}[")); + + let lines = exit_messages(&exit, /*color_enabled*/ true); + assert!(lines[1].contains("\u{1b}[")); + } } diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index fbe904e..3b4ce17 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -17,6 +17,7 @@ use tracing::warn; use devo_provider::ModelProviderSDK; use devo_tools::ToolCall; +use devo_tools::ToolContent; use devo_tools::ToolRegistry; use devo_tools::ToolRuntime; @@ -94,7 +95,7 @@ pub enum QueryEvent { /// A tool call completed. ToolResult { tool_use_id: String, - content: String, + content: ToolContent, display_content: Option, is_error: bool, /// Human-readable summary for client-side rendering (e.g. "bash: npm run dev"). @@ -316,6 +317,17 @@ fn micro_compact(content: String) -> String { } } +fn compact_tool_content(content: ToolContent) -> ToolContent { + match content { + ToolContent::Text(text) => ToolContent::Text(micro_compact(text)), + ToolContent::Json(json) => ToolContent::Json(json), + ToolContent::Mixed { text, json } => ToolContent::Mixed { + text: text.map(micro_compact), + json, + }, + } +} + // --------------------------------------------------------------------------- // Main query loop // --------------------------------------------------------------------------- @@ -808,28 +820,52 @@ pub async fn query( return Ok(()); } + let tool_result_summaries: std::collections::HashMap = tool_calls + .iter() + .map(|call| { + ( + call.id.clone(), + devo_tools::tool_summary::tool_summary(&call.name, &call.input, &session.cwd), + ) + }) + .collect(); + // Execute tool calls. When a caller is observing query events, wire - // tool progress into the same event stream so long-running commands can - // render live output before the final ToolResult arrives. + // tool progress and per-call completion into the same event stream so + // long-running and parallel tools can render before the whole batch ends. let results = if let Some(progress_events) = on_event.clone() { + let completion_events = Arc::clone(&progress_events); + let summaries = Arc::new(tool_result_summaries.clone()); runtime - .execute_batch_streaming(&tool_calls, move |tool_use_id, content| { - progress_events(QueryEvent::ToolProgress { - tool_use_id: tool_use_id.to_string(), - content: content.to_string(), - }); - }) + .execute_batch_streaming_with_completion( + &tool_calls, + move |tool_use_id, content| { + progress_events(QueryEvent::ToolProgress { + tool_use_id: tool_use_id.to_string(), + content: content.to_string(), + }); + }, + move |result| { + let content = compact_tool_content(result.content.clone()); + let display_content = result.display_content.clone().map(micro_compact); + let summary = summaries + .get(result.tool_use_id.as_str()) + .cloned() + .unwrap_or_default(); + completion_events(QueryEvent::ToolResult { + tool_use_id: result.tool_use_id.clone(), + content, + display_content, + is_error: result.is_error, + summary, + }); + }, + ) .await } else { runtime.execute_batch(&tool_calls).await }; - // Build tool call name -> input map for computing summaries - let tool_call_map: std::collections::HashMap<&str, (&str, &serde_json::Value)> = tool_calls - .iter() - .map(|c| (c.id.as_str(), (c.name.as_str(), &c.input))) - .collect(); - // Build tool result message (user role, per Anthropic API convention) // Apply micro-compact to large tool results let result_content: Vec = results @@ -837,20 +873,6 @@ pub async fn query( .map(|r| { let content_str = r.content.into_string(); let compacted_content = micro_compact(content_str); - let compacted_display_content = r.display_content.map(micro_compact); - let summary = tool_call_map - .get(r.tool_use_id.as_str()) - .map(|(name, input)| { - devo_tools::tool_summary::tool_summary(name, input, &session.cwd) - }) - .unwrap_or_default(); - emit(QueryEvent::ToolResult { - tool_use_id: r.tool_use_id.clone(), - content: compacted_content.clone(), - display_content: compacted_display_content, - is_error: r.is_error, - summary: summary.clone(), - }); ContentBlock::ToolResult { tool_use_id: r.tool_use_id, content: compacted_content, @@ -991,6 +1013,10 @@ mod tests { requests: AtomicUsize, } + struct ParallelToolUseProvider { + requests: AtomicUsize, + } + #[async_trait] impl devo_provider::ModelProviderSDK for SingleToolUseProvider { async fn completion(&self, _request: ModelRequest) -> Result { @@ -1055,6 +1081,85 @@ mod tests { } } + #[async_trait] + impl devo_provider::ModelProviderSDK for ParallelToolUseProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let request_number = self.requests.fetch_add(1, Ordering::SeqCst); + + let events = if request_number == 0 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "slow".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 50, + "output": "slow complete", + }), + }), + Ok(StreamEvent::ToolCallStart { + index: 1, + id: "fast".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 5, + "output": "fast complete", + }), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ + ResponseContent::ToolUse { + id: "slow".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 50, + "output": "slow complete", + }), + }, + ResponseContent::ToolUse { + id: "fast".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 5, + "output": "fast complete", + }), + }, + ], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + } else { + vec![Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-2".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + })] + }; + + Ok(Box::pin(futures::stream::iter(events))) + } + + fn name(&self) -> &str { + "parallel-tool-provider" + } + } + struct MutatingTool; struct CapturingProvider { @@ -1235,6 +1340,8 @@ mod tests { struct StreamingMutatingTool; + struct ParallelDelayTool; + #[async_trait] impl ToolHandler for StreamingMutatingTool { fn tool_kind(&self) -> ToolHandlerKind { @@ -1253,6 +1360,32 @@ mod tests { } } + #[async_trait] + impl ToolHandler for ParallelDelayTool { + fn tool_kind(&self) -> ToolHandlerKind { + ToolHandlerKind::Read + } + + async fn handle( + &self, + invocation: ToolInvocation, + _progress: Option, + ) -> Result, ToolExecutionError> { + let delay_ms = invocation + .input + .get("delay_ms") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; + let output = invocation + .input + .get("output") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(Box::new(FunctionToolOutput::success(output))) + } + } + #[tokio::test] async fn query_retries_transient_stream_creation_errors() { let provider = Arc::new(TransientStreamCreateProvider { @@ -1913,10 +2046,13 @@ mod tests { .await .expect("query should complete"); - assert_eq!( - seen.lock().unwrap().as_slice(), - &[(String::from("canonical"), Some(String::from("display")))] - ); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert!(matches!( + &seen[0], + (devo_tools::ToolContent::Text(text), Some(display)) + if text == "canonical" && display == "display" + )); } #[tokio::test] @@ -1984,7 +2120,7 @@ mod tests { is_error, .. } if tool_use_id == "tool-1" - && content == "stream complete" + && matches!(content, devo_tools::ToolContent::Text(text) if text == "stream complete") && !is_error ) }) @@ -1995,4 +2131,84 @@ mod tests { "tool progress should arrive before final result" ); } + + #[tokio::test] + async fn query_emits_parallel_tool_results_as_each_tool_finishes() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("parallel_tool", Arc::new(ParallelDelayTool)); + builder.push_spec(ToolSpec { + name: "parallel_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tools")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback = Arc::new(move |event: QueryEvent| match event { + QueryEvent::ToolUseStart { id, .. } => { + seen_clone + .lock() + .expect("lock events") + .push(format!("start:{id}")); + } + QueryEvent::ToolResult { + tool_use_id, + content, + .. + } => { + let content = content.into_string(); + seen_clone + .lock() + .expect("lock events") + .push(format!("result:{tool_use_id}:{content}")); + } + _ => {} + }); + + query( + &mut session, + &TurnConfig { + model: Model::default(), + thinking_selection: None, + }, + Arc::new(ParallelToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().expect("lock events").as_slice(), + &[ + "start:slow".to_string(), + "start:fast".to_string(), + "result:fast:fast complete".to_string(), + "result:slow:slow complete".to_string(), + ] + ); + + let tool_result_ids = session + .messages + .iter() + .flat_map(|message| &message.content) + .filter_map(|block| match block { + ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(tool_result_ids, vec!["slow", "fast"]); + } } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 11bf804..81e3e5a 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use smol_str::SmolStr; +use crate::parse_command::ParsedCommand; +use crate::protocol::{ExecCommandSource, FileChange}; use crate::session::{SessionMetadata, SessionRuntimeStatus}; use crate::turn::TurnMetadata; use crate::{ItemId, SessionId, TurnId, TurnUsage}; @@ -25,6 +27,8 @@ pub struct ToolCallPayload { pub tool_call_id: String, pub tool_name: String, pub parameters: serde_json::Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_actions: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -44,12 +48,23 @@ pub struct CommandExecutionPayload { pub tool_call_id: String, pub tool_name: String, pub command: String, + #[serde(default)] + pub source: ExecCommandSource, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_actions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub output: Option, #[serde(default)] pub is_error: bool, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FileChangePayload { + pub tool_call_id: String, + pub changes: Vec<(std::path::PathBuf, FileChange)>, + pub is_error: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ItemEventPayload { pub context: EventContext, @@ -70,6 +85,20 @@ pub struct TurnEventPayload { pub turn: TurnMetadata, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnPlanStepPayload { + pub step: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnPlanUpdatedPayload { + pub session_id: SessionId, + pub turn: TurnMetadata, + pub explanation: Option, + pub plan: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TurnUsageUpdatedPayload { pub session_id: SessionId, @@ -215,7 +244,7 @@ pub enum ServerEvent { TurnCompleted(TurnEventPayload), TurnInterrupted(TurnEventPayload), TurnFailed(TurnEventPayload), - TurnPlanUpdated(TurnEventPayload), + TurnPlanUpdated(TurnPlanUpdatedPayload), TurnDiffUpdated(TurnEventPayload), TurnUsageUpdated(TurnUsageUpdatedPayload), InputQueueUpdated(InputQueueUpdatedPayload), @@ -245,8 +274,8 @@ impl ServerEvent { | Self::TurnCompleted(payload) | Self::TurnInterrupted(payload) | Self::TurnFailed(payload) - | Self::TurnPlanUpdated(payload) | Self::TurnDiffUpdated(payload) => Some(payload.session_id), + Self::TurnPlanUpdated(payload) => Some(payload.session_id), Self::TurnUsageUpdated(payload) => Some(payload.session_id), Self::InputQueueUpdated(payload) => Some(payload.session_id), Self::SteerAccepted(payload) => Some(payload.session_id), diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs index 510c07f..80b440f 100644 --- a/crates/protocol/src/session.rs +++ b/crates/protocol/src/session.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use chrono::DateTime; @@ -8,6 +9,8 @@ use serde::Serialize; use crate::ReasoningEffort; use crate::SessionId; use crate::SessionTitleState; +use crate::parse_command::ParsedCommand; +use crate::protocol::FileChange; use crate::turn::TurnMetadata; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -66,7 +69,7 @@ pub struct SessionResumeParams { pub session_id: SessionId, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionResumeResult { pub session: SessionMetadata, pub latest_turn: Option, @@ -90,12 +93,44 @@ pub enum SessionHistoryItemKind { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPlanStepStatus { + Pending, + InProgress, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPlanStep { + pub text: String, + pub status: SessionPlanStepStatus, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SessionHistoryMetadata { + Explored { + actions: Vec, + }, + Edited { + changes: HashMap, + }, + PlanUpdate { + explanation: Option, + steps: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionHistoryItem { pub tool_call_id: Option, pub kind: SessionHistoryItemKind, pub title: String, pub body: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_ms: Option, } @@ -111,9 +146,15 @@ impl SessionHistoryItem { kind, title, body, + metadata: None, duration_ms: None, } } + + pub fn with_metadata(mut self, metadata: SessionHistoryMetadata) -> Self { + self.metadata = Some(metadata); + self + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -178,7 +219,7 @@ pub struct SessionRollbackParams { pub user_turn_index: u32, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionRollbackResult { pub session: SessionMetadata, pub latest_turn: Option, diff --git a/crates/server/src/event.rs b/crates/server/src/event.rs index 3691e50..58a325c 100644 --- a/crates/server/src/event.rs +++ b/crates/server/src/event.rs @@ -1,8 +1,8 @@ pub use devo_protocol::{ ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, EventContext, - ItemDeltaKind, ItemDeltaPayload, ItemEnvelope, ItemEventPayload, ItemKind, + FileChangePayload, ItemDeltaKind, ItemDeltaPayload, ItemEnvelope, ItemEventPayload, ItemKind, PendingServerRequestContext, RequestUserInputPayload, ServerEvent, ServerRequestKind, ServerRequestResolvedPayload, SessionCompactionFailedPayload, SessionEventPayload, SessionStatusChangedPayload, ToolCallPayload, ToolResultPayload, TurnEventPayload, - TurnUsageUpdatedPayload, + TurnPlanStepPayload, TurnPlanUpdatedPayload, TurnUsageUpdatedPayload, }; diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index 454ec55..72decfe 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -2,6 +2,9 @@ use devo_core::{ CommandExecutionItem, ContentBlock, Message, SessionRecord, TextItem, ToolCallItem, ToolResultItem, TurnItem, TurnRecord, }; +use devo_protocol::{SessionHistoryMetadata, SessionPlanStep, SessionPlanStepStatus}; +use devo_utils::git_op::extract_paths_from_patch; +use devo_utils::shell_command::parse_command::parse_command; use crate::session::{ SessionHistoryItem, SessionHistoryItemKind, SessionMetadata, SessionRuntimeStatus, @@ -107,7 +110,6 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Some(SessionHistoryItem::new( @@ -116,6 +118,19 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option { + let metadata = parse_plan_history_metadata(text); + let mut item = SessionHistoryItem::new( + None, + SessionHistoryItemKind::Assistant, + String::new(), + text.clone(), + ); + if let Some(metadata) = metadata { + item = item.with_metadata(metadata); + } + Some(item) + } TurnItem::ContextCompaction(TextItem { .. }) => None, TurnItem::Reasoning(TextItem { text }) => Some(SessionHistoryItem::new( None, @@ -127,12 +142,56 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Some(SessionHistoryItem::new( - Some(tool_call_id.clone()), - SessionHistoryItemKind::ToolCall, - summarize_tool_call(tool_name, input), - String::new(), - )), + }) => { + let title = summarize_tool_call(tool_name, input); + let mut item = SessionHistoryItem::new( + Some(tool_call_id.clone()), + SessionHistoryItemKind::ToolCall, + title.clone(), + String::new(), + ); + if matches!(tool_name.as_str(), "read" | "glob" | "grep") { + let parsed = match tool_name.as_str() { + "read" => { + let path = input + .get("filePath") + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let name = std::path::Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()); + vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: title.clone(), + name, + path: std::path::PathBuf::from(path), + }] + } + "glob" => vec![devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: title.clone(), + path: input + .get("path") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }], + "grep" => vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: title.clone(), + query: input + .get("pattern") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + path: input + .get("path") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }], + _ => Vec::new(), + }; + item = item.with_metadata(SessionHistoryMetadata::Explored { actions: parsed }); + } + Some(item) + } TurnItem::ToolResult(ToolResultItem { tool_call_id, tool_name, @@ -140,38 +199,63 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Some(SessionHistoryItem::new( - Some(tool_call_id.clone()), - if *is_error { - SessionHistoryItemKind::Error - } else { - SessionHistoryItemKind::ToolResult - }, - summarize_tool_result(tool_name.as_deref(), *is_error), - display_content.clone().unwrap_or_else(|| match output { - serde_json::Value::String(text) => text.clone(), - other => other.to_string(), - }), - )), + }) => { + let mut item = SessionHistoryItem::new( + Some(tool_call_id.clone()), + if *is_error { + SessionHistoryItemKind::Error + } else { + SessionHistoryItemKind::ToolResult + }, + summarize_tool_result(tool_name.as_deref(), *is_error), + display_content.clone().unwrap_or_else(|| match output { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }), + ); + if !*is_error + && tool_name.as_deref() == Some("update_plan") + && let Some(metadata) = match output { + serde_json::Value::String(text) => parse_plan_history_metadata(text), + other => parse_plan_history_metadata(&other.to_string()), + } + { + item = item.with_metadata(metadata); + } + if !*is_error + && matches!(tool_name.as_deref(), Some("apply_patch" | "write")) + && let Some(metadata) = parse_edited_history_metadata(output) + { + item = item.with_metadata(metadata); + } + Some(item) + } TurnItem::CommandExecution(CommandExecutionItem { tool_call_id, command, output, is_error, .. - }) => Some(SessionHistoryItem::new( - Some(tool_call_id.clone()), - if *is_error { - SessionHistoryItemKind::Error - } else { - SessionHistoryItemKind::CommandExecution - }, - command.clone(), - match output { - serde_json::Value::String(text) => text.clone(), - other => other.to_string(), - }, - )), + }) => { + let parsed = parse_command(std::slice::from_ref(command)); + let mut item = SessionHistoryItem::new( + Some(tool_call_id.clone()), + if *is_error { + SessionHistoryItemKind::Error + } else { + SessionHistoryItemKind::CommandExecution + }, + command.clone(), + match output { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }, + ); + if !parsed.is_empty() { + item = item.with_metadata(SessionHistoryMetadata::Explored { actions: parsed }); + } + Some(item) + } TurnItem::ToolProgress(_) | TurnItem::ApprovalRequest(_) | TurnItem::ApprovalDecision(_) => None, @@ -186,12 +270,96 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Option { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + let explanation = value + .get("explanation") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .filter(|text| !text.trim().is_empty()); + let steps = value + .get("plan") + .and_then(serde_json::Value::as_array)? + .iter() + .filter_map(|item| { + let text = item.get("step")?.as_str()?.to_string(); + let status = match item.get("status").and_then(serde_json::Value::as_str)? { + "pending" => SessionPlanStepStatus::Pending, + "in_progress" => SessionPlanStepStatus::InProgress, + "completed" => SessionPlanStepStatus::Completed, + "cancelled" => SessionPlanStepStatus::Cancelled, + _ => return None, + }; + Some(SessionPlanStep { text, status }) + }) + .collect::>(); + Some(SessionHistoryMetadata::PlanUpdate { explanation, steps }) +} + +fn parse_edited_history_metadata(output: &serde_json::Value) -> Option { + let files = output.get("files")?.as_array()?; + let diff = output + .get("diff") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let mut changes = std::collections::HashMap::new(); + for file in files { + let path = std::path::PathBuf::from(file.get("path")?.as_str()?); + let kind = file.get("kind")?.as_str()?; + let additions = file + .get("additions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let deletions = file + .get("deletions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let change = match kind { + "add" => devo_protocol::protocol::FileChange::Add { + content: "\n".repeat(additions as usize), + }, + "delete" => devo_protocol::protocol::FileChange::Delete { + content: "\n".repeat(deletions as usize), + }, + "update" | "move" => devo_protocol::protocol::FileChange::Update { + 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()), + move_path: file + .get("movePath") + .or_else(|| file.get("move_path")) + .and_then(serde_json::Value::as_str) + .map(std::path::PathBuf::from), + }, + _ => continue, + }; + changes.insert(path, change); + } + if changes.is_empty() && !diff.is_empty() { + for path in extract_paths_from_patch(&diff) { + changes.insert( + std::path::PathBuf::from(path), + devo_protocol::protocol::FileChange::Update { + unified_diff: diff.clone(), + move_path: None, + }, + ); + } + } + (!changes.is_empty()).then_some(SessionHistoryMetadata::Edited { changes }) +} + impl SessionProjector for DefaultProjection { fn project_session( &self, @@ -278,8 +446,9 @@ mod tests { use super::history_item_from_turn_item; use crate::session::SessionHistoryItemKind; - use devo_core::ToolResultItem; use devo_core::TurnItem; + use devo_core::{CommandExecutionItem, TextItem, ToolCallItem, ToolResultItem}; + use devo_protocol::{SessionHistoryMetadata, SessionPlanStepStatus}; #[test] fn history_projection_prefers_tool_result_display_content() { @@ -310,4 +479,179 @@ mod tests { let history_item = history_item_from_turn_item(&item).expect("history item"); assert_eq!(history_item.body, "canonical"); } + + #[test] + fn plan_turn_item_emits_structured_plan_metadata() { + let item = TurnItem::Plan(TextItem { + text: r#"{"explanation":"Do work","plan":[{"step":"Inspect","status":"completed"},{"step":"Patch","status":"in_progress"}]}"#.to_string(), + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + let SessionHistoryMetadata::PlanUpdate { explanation, steps } = + history_item.metadata.expect("plan metadata") + else { + panic!("expected plan update metadata"); + }; + assert_eq!(explanation, Some("Do work".to_string())); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].status, SessionPlanStepStatus::Completed); + assert_eq!(steps[1].status, SessionPlanStepStatus::InProgress); + } + + #[test] + fn command_execution_turn_item_emits_explored_metadata() { + let item = TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: "call-1".to_string(), + tool_name: "exec_command".to_string(), + command: "cat foo.txt".to_string(), + input: serde_json::json!({}), + output: serde_json::Value::String("hello".to_string()), + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + match history_item.metadata.expect("explored metadata") { + SessionHistoryMetadata::Explored { actions } => { + assert!(!actions.is_empty(), "expected parsed command actions"); + } + other => panic!("unexpected metadata: {other:?}"), + } + } + + #[test] + fn read_tool_call_turn_item_emits_explored_metadata() { + let item = TurnItem::ToolCall(ToolCallItem { + tool_call_id: "call-1".to_string(), + tool_name: "read".to_string(), + input: serde_json::json!({ + "filePath": "crates/tui/src/chatwidget.rs" + }), + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + match history_item.metadata.expect("explored metadata") { + SessionHistoryMetadata::Explored { actions } => { + assert!(matches!( + &actions[0], + devo_protocol::parse_command::ParsedCommand::Read { name, .. } + if name == "chatwidget.rs" + )); + } + other => panic!("unexpected metadata: {other:?}"), + } + } + + #[test] + fn update_plan_tool_result_emits_plan_metadata() { + let item = TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("update_plan".to_string()), + output: serde_json::json!({ + "explanation": "", + "plan": [ + { "step": "创建一个示例计划,展示 plan 工具的使用方式", "status": "in_progress" }, + { "step": "再添加一个已完成步骤作为对比", "status": "completed" }, + { "step": "最后留一个待处理步骤", "status": "pending" } + ] + }), + display_content: None, + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + let SessionHistoryMetadata::PlanUpdate { steps, .. } = + history_item.metadata.expect("plan metadata") + else { + panic!("expected plan update metadata"); + }; + assert_eq!(steps.len(), 3); + } + + #[test] + fn write_tool_result_emits_edited_metadata() { + let item = TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("write".to_string()), + 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", + "additions": 1, + "deletions": 1 + } + ] + }), + display_content: None, + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + let SessionHistoryMetadata::Edited { changes } = + history_item.metadata.expect("edited metadata") + else { + panic!("expected edited metadata"); + }; + assert!(changes.contains_key(&std::path::PathBuf::from("foo.txt"))); + } + + #[test] + fn write_tool_result_with_diff_only_still_emits_edited_metadata() { + let item = TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("write".to_string()), + 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": [] + }), + display_content: None, + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + let SessionHistoryMetadata::Edited { changes } = + history_item.metadata.expect("edited metadata") + else { + panic!("expected edited metadata"); + }; + assert!(changes.contains_key(&std::path::PathBuf::from("foo.txt"))); + } + + #[test] + fn edited_metadata_prefers_file_local_diff_over_top_level_diff() { + let item = TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("apply_patch".to_string()), + output: serde_json::json!({ + "diff": "BROKEN TOP LEVEL DIFF", + "files": [ + { + "path": "foo.txt", + "kind": "update", + "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", + "additions": 1, + "deletions": 1 + } + ] + }), + display_content: None, + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + let SessionHistoryMetadata::Edited { changes } = + history_item.metadata.expect("edited metadata") + else { + panic!("expected edited metadata"); + }; + let devo_protocol::protocol::FileChange::Update { unified_diff, .. } = changes + .get(&std::path::PathBuf::from("foo.txt")) + .expect("update change") + else { + panic!("expected update change"); + }; + assert!(unified_diff.contains("--- a/foo.txt")); + assert!(!unified_diff.contains("BROKEN TOP LEVEL DIFF")); + } } diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index 7582511..f3e0814 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::mpsc; - use super::*; +use crate::{FileChangePayload, TurnPlanStepPayload, TurnPlanUpdatedPayload}; +use devo_utils::git_op::extract_paths_from_patch; +use tokio::sync::mpsc; struct PendingToolCall { item_id: ItemId, @@ -59,6 +60,14 @@ fn is_unified_exec_tool(name: &str) -> bool { matches!(name, "exec_command" | "write_stdin") } +fn is_file_change_tool(name: &str) -> bool { + matches!(name, "apply_patch" | "write") +} + +fn is_plan_tool(name: &str) -> bool { + matches!(name, "update_plan") +} + fn command_display_from_input(tool_name: &str, input: &serde_json::Value) -> String { match tool_name { "exec_command" => input @@ -83,10 +92,92 @@ fn command_display_from_input(tool_name: &str, input: &serde_json::Value) -> Str format!("write_stdin session {session_id}") } } + "read" => { + let path = input + .get("filePath") + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + format!("read {path}") + } + "glob" => { + let pattern = input + .get("pattern") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if path.is_empty() { + format!("glob {pattern}") + } else { + format!("glob {pattern} in {path}") + } + } + "grep" => { + let pattern = input + .get("pattern") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if path.is_empty() { + format!("grep {pattern}") + } else { + format!("grep {pattern} in {path}") + } + } _ => String::new(), } } +fn command_actions_from_tool_input( + tool_name: &str, + command: &str, + input: &serde_json::Value, +) -> Vec { + match tool_name { + "read" => { + let path = input + .get("filePath") + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let name = std::path::Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()); + vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: command.to_string(), + name, + path: std::path::PathBuf::from(path), + }] + } + "glob" => vec![devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: command.to_string(), + path: input + .get("path") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }], + "grep" => vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: command.to_string(), + query: input + .get("pattern") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + path: input + .get("path") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }], + _ => Vec::new(), + } +} + fn command_execution_item_id_for_progress( pending_tool_calls: &HashMap, tool_use_id: &str, @@ -131,6 +222,7 @@ impl ServerRuntime { let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); let runtime = Arc::clone(&self); let turn_for_events = turn.clone(); + let turn_for_plan_updates = turn.clone(); let event_session_arc = Arc::clone(&session_arc); let event_task = tokio::spawn(async move { // This task owns the streamed model output. It turns raw query @@ -285,25 +377,48 @@ impl ServerRuntime { } let is_command_execution = is_unified_exec_tool(&name); let command = command_display_from_input(&name, &input); - let item_kind = if is_command_execution { + let item_kind = if is_file_change_tool(&name) { + ItemKind::FileChange + } else if is_command_execution { ItemKind::CommandExecution + } else if is_plan_tool(&name) { + ItemKind::Plan } else { ItemKind::ToolCall }; - let started_payload = if is_command_execution { + let started_payload = if is_file_change_tool(&name) { + serde_json::to_value(FileChangePayload { + tool_call_id: id.clone(), + changes: Vec::new(), + is_error: false, + }) + .expect("serialize file change payload") + } else if is_command_execution { serde_json::to_value(CommandExecutionPayload { tool_call_id: id.clone(), tool_name: name.clone(), command: command.clone(), + source: devo_protocol::protocol::ExecCommandSource::Agent, + command_actions: command_actions_from_tool_input( + &name, &command, &input, + ), output: None, is_error: false, }) .expect("serialize command execution payload") + } else if is_plan_tool(&name) { + serde_json::json!({ + "title": "Plan", + "text": "" + }) } else { 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") }; @@ -337,14 +452,207 @@ impl ServerRuntime { // 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(tool_name) = tool_name.clone() + && is_plan_tool(&tool_name) + { + let output_json = match content.clone() { + devo_tools::ToolContent::Text(text) => { + serde_json::Value::String(text) + } + devo_tools::ToolContent::Json(json) => json, + devo_tools::ToolContent::Mixed { text, json } => json + .unwrap_or_else(|| { + serde_json::Value::String(text.unwrap_or_default()) + }), + }; + let explanation = output_json + .get("explanation") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned); + let plan = output_json + .get("plan") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + + runtime + .complete_item( + session_id, + turn_for_events.turn_id, + pending.item_id, + pending.item_seq, + ItemKind::Plan, + TurnItem::Plan(TextItem { + text: output_json.to_string(), + }), + serde_json::json!({ + "title": "Plan", + "text": output_json.to_string(), + }), + ) + .await; + + runtime + .broadcast_event(ServerEvent::TurnPlanUpdated( + TurnPlanUpdatedPayload { + session_id, + turn: turn_for_plan_updates.clone(), + explanation, + plan: plan + .into_iter() + .filter_map(|item| { + Some(TurnPlanStepPayload { + step: item + .get("step")? + .as_str()? + .to_string(), + status: item + .get("status")? + .as_str()? + .to_string(), + }) + }) + .collect(), + }, + )) + .await; + continue; + } + + if let Some(tool_name) = tool_name.clone() + && is_file_change_tool(&tool_name) + { + let output_json = match content.clone() { + devo_tools::ToolContent::Text(text) => { + serde_json::Value::String(text) + } + devo_tools::ToolContent::Json(json) => json, + devo_tools::ToolContent::Mixed { text, json } => json + .unwrap_or_else(|| { + serde_json::Value::String(text.unwrap_or_default()) + }), + }; + let changes = output_json + .get("files") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|file| { + let path = + std::path::PathBuf::from(file.get("path")?.as_str()?); + let kind = file.get("kind")?.as_str()?; + let additions = file + .get("additions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let deletions = file + .get("deletions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let change = match kind { + "add" => devo_protocol::protocol::FileChange::Add { + content: "\n".repeat(additions as usize), + }, + "delete" => { + devo_protocol::protocol::FileChange::Delete { + content: "\n".repeat(deletions as usize), + } + } + "update" | "move" => { + devo_protocol::protocol::FileChange::Update { + unified_diff: file + .get("diff") + .or_else(|| file.get("patch")) + .or_else(|| output_json.get("diff")) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + move_path: file + .get("movePath") + .or_else(|| file.get("move_path")) + .and_then(serde_json::Value::as_str) + .map(std::path::PathBuf::from), + } + } + _ => return None, + }; + Some((path, change)) + }) + .collect::>(); + let changes = if changes.is_empty() { + output_json + .get("diff") + .and_then(serde_json::Value::as_str) + .map(extract_paths_from_patch) + .unwrap_or_default() + .into_iter() + .map(|path| { + ( + std::path::PathBuf::from(path), + devo_protocol::protocol::FileChange::Update { + unified_diff: output_json + .get("diff") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + move_path: None, + }, + ) + }) + .collect::>() + } else { + changes + }; + + runtime + .complete_item( + session_id, + turn_for_events.turn_id, + pending.item_id, + pending.item_seq, + ItemKind::FileChange, + TurnItem::ToolResult(ToolResultItem { + tool_call_id: tool_use_id.clone(), + tool_name: Some(tool_name.clone()), + output: output_json.clone(), + display_content: display_content.clone(), + is_error, + }), + serde_json::to_value(FileChangePayload { + tool_call_id: tool_use_id.clone(), + changes, + is_error, + }) + .expect("serialize file change payload"), + ) + .await; + continue; + } + if pending.is_command_execution { let tool_name = tool_name.clone().unwrap_or_default(); - let output = serde_json::Value::String(content.clone()); + let output = match content.clone() { + devo_tools::ToolContent::Text(text) => { + serde_json::Value::String(text) + } + devo_tools::ToolContent::Json(json) => json, + devo_tools::ToolContent::Mixed { text, json } => json + .unwrap_or_else(|| { + serde_json::Value::String(text.unwrap_or_default()) + }), + }; let completed_payload = 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: Some(output.clone()), is_error, }) @@ -373,6 +681,11 @@ impl ServerRuntime { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone().unwrap_or_default(), parameters: pending.input.clone(), + command_actions: command_actions_from_tool_input( + tool_name.clone().unwrap_or_default().as_str(), + &pending.command, + &pending.input, + ), }) .expect("serialize tool call payload"); runtime @@ -399,14 +712,32 @@ impl ServerRuntime { TurnItem::ToolResult(ToolResultItem { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone(), - output: serde_json::Value::String(content.clone()), + output: match content.clone() { + devo_tools::ToolContent::Text(text) => { + serde_json::Value::String(text) + } + devo_tools::ToolContent::Json(json) => json, + devo_tools::ToolContent::Mixed { text, json } => json + .unwrap_or_else(|| { + serde_json::Value::String(text.unwrap_or_default()) + }), + }, display_content: display_content.clone(), is_error, }), serde_json::to_value(ToolResultPayload { tool_call_id: tool_use_id.clone(), tool_name, - content: serde_json::Value::String(content), + content: match content { + devo_tools::ToolContent::Text(text) => { + serde_json::Value::String(text) + } + devo_tools::ToolContent::Json(json) => json, + devo_tools::ToolContent::Mixed { text, json } => json + .unwrap_or_else(|| { + serde_json::Value::String(text.unwrap_or_default()) + }), + }, display_content, is_error, summary, @@ -1066,4 +1397,55 @@ mod tests { None ); } + + #[test] + fn file_change_tool_detection_matches_apply_patch_and_write() { + assert!(is_file_change_tool("apply_patch")); + assert!(is_file_change_tool("write")); + assert!(!is_file_change_tool("read")); + } + + #[test] + fn plan_tool_detection_matches_update_plan() { + assert!(is_plan_tool("update_plan")); + assert!(!is_plan_tool("read")); + } + + #[test] + fn command_actions_from_read_tool_input_builds_read_action() { + let actions = command_actions_from_tool_input( + "read", + "read crates/tui/src/chatwidget.rs", + &serde_json::json!({ + "filePath": "crates/tui/src/chatwidget.rs" + }), + ); + assert_eq!( + actions, + vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read crates/tui/src/chatwidget.rs".to_string(), + name: "chatwidget.rs".to_string(), + path: std::path::PathBuf::from("crates/tui/src/chatwidget.rs"), + }] + ); + } + + #[test] + fn command_actions_from_grep_tool_input_builds_search_action() { + let actions = command_actions_from_tool_input( + "grep", + "grep rebuild_restored_session in crates/tui/src", + &serde_json::json!({ + "pattern": "rebuild_restored_session", + "path": "crates/tui/src" + }), + ); + assert_eq!(actions.len(), 1); + assert!(matches!( + &actions[0], + devo_protocol::parse_command::ParsedCommand::Search { query, path, .. } + if query.as_deref() == Some("rebuild_restored_session") + && path.as_deref() == Some("crates/tui/src") + )); + } } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index f2868e5..234ae8c 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -14,6 +14,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } base64 = "0.22" chrono = { workspace = true } +diffy = { workspace = true } futures = { workspace = true } glob = { workspace = true } portable-pty = "0.9" diff --git a/crates/tools/src/apply_patch.rs b/crates/tools/src/apply_patch.rs index 545c3aa..84e9b05 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -80,18 +80,82 @@ pub(crate) async fn exec_apply_patch( let relative_path = relative_worktree_path(target_path.as_ref().unwrap_or(&source_path), cwd); let kind_name = change.kind.as_str(); - let diff = format!("--- {}\n+++ {}\n", relative_path, relative_path); + let diff = match change.kind { + PatchKind::Add => { + let content = if change.content.ends_with('\n') { + change.content.clone() + } else { + format!("{}\n", change.content) + }; + format!( + "diff --git a/{0} b/{0}\nnew file mode 100644\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}", + relative_path, + additions, + content + .lines() + .map(|line| format!("+{line}")) + .collect::>() + .join("\n") + ) + } + PatchKind::Delete => { + let deleted = if old_content.ends_with('\n') { + old_content.clone() + } else { + format!("{old_content}\n") + }; + format!( + "diff --git a/{0} b/{0}\ndeleted file mode 100644\n--- a/{0}\n+++ /dev/null\n@@ -1,{1} +0,0 @@\n{2}", + relative_path, + deletions, + deleted + .lines() + .map(|line| format!("-{line}")) + .collect::>() + .join("\n") + ) + } + PatchKind::Update | PatchKind::Move => { + let old_line_count = old_content.lines().count(); + let new_line_count = new_content.lines().count(); + let patch_body = change + .hunks + .iter() + .flat_map(|hunk| { + let mut lines = Vec::with_capacity(hunk.lines.len() + 1); + lines.push(format!("@@ -1,{old_line_count} +1,{new_line_count} @@")); + lines.extend(hunk.lines.iter().map(|line| match line { + HunkLine::Context(text) => format!(" {text}"), + HunkLine::Remove(text) => format!("-{text}"), + HunkLine::Add(text) => format!("+{text}"), + })); + lines + }) + .collect::>() + .join("\n"); + format!( + "diff --git a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n{1}\n", + relative_path, patch_body + ) + } + }; files.push(json!({ + "path": relative_path, "filePath": source_path, "relativePath": relative_path, + "kind": kind_name, "type": kind_name, + "diff": diff, "patch": diff, "additions": additions, "deletions": deletions, "movePath": target_path, })); - total_diff.push_str(&diff); + if !total_diff.is_empty() { + total_diff.push('\n'); + } + total_diff.push_str(diff.trim_end()); total_diff.push('\n'); summary.push(match change.kind { @@ -980,5 +1044,26 @@ hello assert_eq!(files[2]["deletions"], 1); assert_eq!(files[3]["additions"], 1); assert_eq!(files[3]["deletions"], 1); + + for file in files { + if file["kind"] == "update" || file["kind"] == "move" { + let per_file_diff = file + .get("diff") + .or_else(|| file.get("patch")) + .and_then(serde_json::Value::as_str) + .expect("per-file diff"); + let patch = + diffy::Patch::from_str(per_file_diff).expect("per-file diff should parse"); + let (added, removed) = patch.hunks().iter().flat_map(diffy::Hunk::lines).fold( + (0usize, 0usize), + |(a, d), line| match line { + diffy::Line::Insert(_) => (a + 1, d), + diffy::Line::Delete(_) => (a, d + 1), + diffy::Line::Context(_) => (a, d), + }, + ); + assert_eq!((added, removed), (1, 1)); + } + } } } diff --git a/crates/tools/src/bash.txt b/crates/tools/src/bash.txt index b133105..df733e5 100644 --- a/crates/tools/src/bash.txt +++ b/crates/tools/src/bash.txt @@ -86,7 +86,7 @@ Git Safety Protocol: Important notes: - NEVER run additional commands to read or explore code, besides git bash commands -- NEVER use the TodoWrite or Task tools +- NEVER use the Task tool - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit @@ -113,7 +113,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF' Important: -- DO NOT use the TodoWrite or Task tools +- DO NOT use the Task tool - Return the PR URL when you're done, so the user can see it # Other common operations diff --git a/crates/tools/src/handler_kind.rs b/crates/tools/src/handler_kind.rs index 6b6a101..4101777 100644 --- a/crates/tools/src/handler_kind.rs +++ b/crates/tools/src/handler_kind.rs @@ -10,7 +10,6 @@ pub enum ToolHandlerKind { Plan, Question, Task, - TodoWrite, WebFetch, WebSearch, Skill, diff --git a/crates/tools/src/handlers/file_write.rs b/crates/tools/src/handlers/file_write.rs index 5cba744..da3b534 100644 --- a/crates/tools/src/handlers/file_write.rs +++ b/crates/tools/src/handlers/file_write.rs @@ -1,6 +1,9 @@ use std::path::PathBuf; use async_trait::async_trait; +use diffy::PatchFormatter; +use diffy::create_patch; +use serde_json::json; use tracing::info; use crate::errors::ToolExecutionError; @@ -35,6 +38,7 @@ impl ToolHandler for WriteHandler { let path = resolve_path(&invocation.cwd, path_str); info!(path = %path.display(), bytes = content.len(), "writing file"); + let previous = tokio::fs::read_to_string(&path).await.ok(); if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await.map_err(|e| { @@ -50,11 +54,11 @@ impl ToolHandler for WriteHandler { } })?; - Ok(Box::new(FunctionToolOutput::success(format!( - "wrote {} bytes to {}", - content.len(), - path.display() - )))) + let metadata = build_write_metadata(&path, previous.as_deref(), content); + Ok(Box::new(FunctionToolOutput::success_with_metadata( + format!("wrote {} bytes to {}", content.len(), path.display()), + metadata, + ))) } } @@ -62,3 +66,83 @@ fn resolve_path(cwd: &std::path::Path, path: &str) -> PathBuf { let p = PathBuf::from(path); if p.is_absolute() { p } else { cwd.join(p) } } + +fn build_write_metadata( + path: &std::path::Path, + previous: Option<&str>, + content: &str, +) -> serde_json::Value { + match previous { + None => json!({ + "diff": format!( + "diff --git a/{0} b/{0}\nnew file mode 100644\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}", + path.display(), + content.lines().count(), + content + .lines() + .map(|line| format!("+{line}")) + .collect::>() + .join("\n") + ), + "files": [{ + "path": path.display().to_string(), + "kind": "add", + "additions": content.lines().count(), + "deletions": 0 + }] + }), + Some(old) => { + let patch = create_patch(old, content); + let patch_text = PatchFormatter::new().fmt_patch(&patch).to_string(); + let additions = content.lines().count(); + let deletions = old.lines().count(); + json!({ + "diff": format!( + "diff --git a/{0} b/{0}\n{1}", + path.display(), + patch_text + ), + "files": [{ + "path": path.display().to_string(), + "kind": "update", + "additions": additions, + "deletions": deletions + }] + }) + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn build_write_metadata_for_new_file_marks_add() { + let metadata = + build_write_metadata(std::path::Path::new("foo.txt"), None, "hello\nworld\n"); + assert_eq!(metadata["files"][0]["kind"], "add"); + assert_eq!(metadata["files"][0]["additions"], 2); + } + + #[test] + fn build_write_metadata_for_existing_file_marks_update() { + let metadata = + build_write_metadata(std::path::Path::new("foo.txt"), Some("old\n"), "new\n"); + assert_eq!(metadata["files"][0]["kind"], "update"); + assert!( + metadata["diff"] + .as_str() + .unwrap_or_default() + .contains("diff --git a/foo.txt b/foo.txt") + ); + assert!( + metadata["diff"] + .as_str() + .unwrap_or_default() + .contains("@@ -1 +1 @@") + ); + } +} diff --git a/crates/tools/src/handlers/mod.rs b/crates/tools/src/handlers/mod.rs index 777e677..122998d 100644 --- a/crates/tools/src/handlers/mod.rs +++ b/crates/tools/src/handlers/mod.rs @@ -12,7 +12,6 @@ mod read; mod shell_command; mod skill; mod task; -mod todo_write; mod webfetch; mod websearch; @@ -30,7 +29,6 @@ pub use read::ReadHandler; pub use shell_command::ShellCommandHandler; pub use skill::SkillHandler; pub use task::TaskHandler; -pub use todo_write::TodoWriteHandler; pub use webfetch::WebFetchHandler; pub use websearch::WebSearchHandler; @@ -65,7 +63,6 @@ pub fn build_registry_from_plan(config: &ToolPlanConfig) -> crate::registry::Too ToolHandlerKind::Plan => Arc::new(PlanHandler), ToolHandlerKind::Question => Arc::new(QuestionHandler), ToolHandlerKind::Task => Arc::new(TaskHandler), - ToolHandlerKind::TodoWrite => Arc::new(TodoWriteHandler), ToolHandlerKind::WebFetch => Arc::new(WebFetchHandler), ToolHandlerKind::WebSearch => Arc::new(WebSearchHandler), ToolHandlerKind::Skill => Arc::new(SkillHandler), diff --git a/crates/tools/src/handlers/todo_write.rs b/crates/tools/src/handlers/todo_write.rs deleted file mode 100644 index ede06aa..0000000 --- a/crates/tools/src/handlers/todo_write.rs +++ /dev/null @@ -1,34 +0,0 @@ -use async_trait::async_trait; - -use crate::errors::ToolExecutionError; -use crate::events::ToolProgressSender; -use crate::handler_kind::ToolHandlerKind; -use crate::invocation::{FunctionToolOutput, ToolInvocation, ToolOutput}; -use crate::tool_handler::ToolHandler; - -pub struct TodoWriteHandler; - -#[async_trait] -impl ToolHandler for TodoWriteHandler { - fn tool_kind(&self) -> ToolHandlerKind { - ToolHandlerKind::TodoWrite - } - - async fn handle( - &self, - invocation: ToolInvocation, - _progress: Option, - ) -> Result, ToolExecutionError> { - let todos = invocation.input["todos"] - .as_array() - .cloned() - .unwrap_or_default(); - Ok(Box::new(FunctionToolOutput::success( - serde_json::to_string_pretty(&todos).map_err(|e| { - ToolExecutionError::ExecutionFailed { - message: e.to_string(), - } - })?, - ))) - } -} diff --git a/crates/tools/src/handlers/websearch.rs b/crates/tools/src/handlers/websearch.rs index 8711f7f..4559127 100644 --- a/crates/tools/src/handlers/websearch.rs +++ b/crates/tools/src/handlers/websearch.rs @@ -3,9 +3,22 @@ use async_trait::async_trait; use crate::errors::ToolExecutionError; use crate::events::ToolProgressSender; use crate::handler_kind::ToolHandlerKind; -use crate::invocation::{FunctionToolOutput, ToolInvocation, ToolOutput}; +use crate::invocation::FunctionToolOutput; +use crate::invocation::ToolInvocation; +use crate::invocation::ToolOutput; use crate::tool_handler::ToolHandler; +// TODO: WebSearch is a critical agent tool because it gives the agent access to +// external information beyond the local workspace. It should be designed as an +// extensible, pluggable provider interface, allowing the runtime to connect to +// multiple public search engines now and enterprise/private knowledge bases in +// the future. +// +// Define a stable WebSearch API contract here, including the request/response +// schema, provider configuration, authentication, timeout/retry behavior, rate +// limits, error handling, and fallback strategy. The agent core should depend on +// the WebSearch abstraction rather than any specific search provider. + pub struct WebSearchHandler; #[async_trait] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index db817fa..3e5a250 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -42,7 +42,7 @@ pub fn create_default_tool_registry() -> registry::ToolRegistry { mod tests { use super::*; - fn expected_tool_names_default() -> [&'static str; 17] { + fn expected_tool_names_default() -> [&'static str; 16] { [ "bash", "read", @@ -52,7 +52,6 @@ mod tests { "invalid", "question", "task", - "todowrite", "webfetch", "websearch", "skill", diff --git a/crates/tools/src/registry_plan.rs b/crates/tools/src/registry_plan.rs index 93e2941..b51263b 100644 --- a/crates/tools/src/registry_plan.rs +++ b/crates/tools/src/registry_plan.rs @@ -333,43 +333,6 @@ fn task_schema() -> JsonSchema { ) } -fn todowrite_schema() -> JsonSchema { - JsonSchema::object( - BTreeMap::from([( - "todos".to_string(), - JsonSchema::array( - JsonSchema::object( - BTreeMap::from([ - ( - "content".to_string(), - JsonSchema::string(Some("Brief description of the task")), - ), - ( - "status".to_string(), - JsonSchema::string(Some( - "Current status: pending, in_progress, completed, cancelled", - )), - ), - ( - "priority".to_string(), - JsonSchema::string(Some("Priority: high, medium, low")), - ), - ]), - Some(vec![ - "content".to_string(), - "status".to_string(), - "priority".to_string(), - ]), - Some(false), - ), - Some("The updated todo list"), - ), - )]), - Some(vec!["todos".to_string()]), - Some(false), - ) -} - fn webfetch_schema() -> JsonSchema { JsonSchema::object( BTreeMap::from([ @@ -666,19 +629,6 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { ToolHandlerKind::Task, ); - plan.push( - ToolSpec { - name: "todowrite".to_string(), - description: "Use this tool to create and manage a structured task list for your current coding session.".to_string(), - input_schema: todowrite_schema(), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![ToolCapabilityTag::WriteFiles], - supports_parallel: false, - }, - ToolHandlerKind::TodoWrite, - ); - plan.push( ToolSpec { name: "webfetch".to_string(), diff --git a/crates/tools/src/router.rs b/crates/tools/src/router.rs index a52b30f..f4d5e3f 100644 --- a/crates/tools/src/router.rs +++ b/crates/tools/src/router.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use std::sync::Arc; use devo_safety::ResourceKind; -use futures::future::join_all; +use futures::StreamExt; +use futures::stream::FuturesUnordered; use tokio::sync::RwLock; use tracing::{info, warn}; @@ -13,6 +14,8 @@ use crate::tool_spec::ToolCapabilityTag; type ProgressCallback = dyn Fn(&str, &str) + Send + Sync; type ProgressCallbackArc = Arc; +type CompletionCallback = dyn Fn(&ToolCallResult) + Send + Sync; +type CompletionCallbackArc = Arc; type PermissionFuture = futures::future::BoxFuture<'static, Result<(), String>>; type PermissionCheckFn = dyn Fn(ToolPermissionRequest) -> PermissionFuture + Send + Sync; const PROGRESS_DRAIN_GRACE_MS: u64 = 50; @@ -92,7 +95,10 @@ impl ToolRuntime { } pub async fn execute_batch(&self, calls: &[ToolCall]) -> Vec { - self.execute_batch_inner(calls, None).await + self.execute_batch_inner( + calls, /*on_progress*/ None, /*on_completion*/ None, + ) + .await } pub async fn execute_batch_streaming( @@ -100,41 +106,76 @@ impl ToolRuntime { calls: &[ToolCall], on_progress: impl Fn(&str, &str) + Send + Sync + 'static, ) -> Vec { - self.execute_batch_inner(calls, Some(Box::new(on_progress))) - .await + self.execute_batch_inner( + calls, + Some(Box::new(on_progress)), + /*on_completion*/ None, + ) + .await + } + + pub async fn execute_batch_streaming_with_completion( + &self, + calls: &[ToolCall], + on_progress: impl Fn(&str, &str) + Send + Sync + 'static, + on_completion: impl Fn(&ToolCallResult) + Send + Sync + 'static, + ) -> Vec { + self.execute_batch_inner( + calls, + Some(Box::new(on_progress)), + Some(Box::new(on_completion)), + ) + .await } async fn execute_batch_inner( &self, calls: &[ToolCall], on_progress: Option>, + on_completion: Option>, ) -> Vec { // Wrap the Box in an Arc so it can be shared across spawned tasks let on_progress: Option = on_progress.map(Arc::from); + let on_completion: Option = on_completion.map(Arc::from); - let mut results = Vec::with_capacity(calls.len()); + let mut indexed_results = Vec::with_capacity(calls.len()); let (parallel, exclusive): (Vec<_>, Vec<_>) = calls .iter() - .partition(|call| self.registry.supports_parallel(&call.name)); + .enumerate() + .partition(|(_, call)| self.registry.supports_parallel(&call.name)); if !parallel.is_empty() { let _guard = self.gate.read().await; - let futures: Vec<_> = parallel + let mut futures: FuturesUnordered<_> = parallel .iter() - .map(|call| self.execute_single(call, &on_progress)) + .map(|(index, call)| { + let on_progress = on_progress.clone(); + async move { (*index, self.execute_single(call, &on_progress).await) } + }) .collect(); - let parallel_results = join_all(futures).await; - results.extend(parallel_results); + while let Some((index, result)) = futures.next().await { + if let Some(callback) = &on_completion { + callback(&result); + } + indexed_results.push((index, result)); + } } - for call in &exclusive { + for (index, call) in exclusive { let _guard = self.gate.write().await; let result = self.execute_single(call, &on_progress).await; - results.push(result); + if let Some(callback) = &on_completion { + callback(&result); + } + indexed_results.push((index, result)); } - results + indexed_results.sort_by_key(|(index, _)| *index); + indexed_results + .into_iter() + .map(|(_, result)| result) + .collect() } pub(crate) async fn execute_single( @@ -542,6 +583,34 @@ mod tests { } } + struct DelayedReadTool; + + #[async_trait] + impl ToolHandler for DelayedReadTool { + fn tool_kind(&self) -> ToolHandlerKind { + ToolHandlerKind::Read + } + + async fn handle( + &self, + invocation: ToolInvocation, + _progress: Option, + ) -> Result, ToolExecutionError> { + let delay_ms = invocation + .input + .get("delay_ms") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; + let output = invocation + .input + .get("output") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(Box::new(FunctionToolOutput::success(output))) + } + } + fn make_registry() -> Arc { let mut builder = ToolRegistryBuilder::new(); builder.register_handler("read_tool", Arc::new(ReadOnlyTool)); @@ -564,6 +633,16 @@ mod tests { capability_tags: vec![ToolCapabilityTag::WriteFiles], supports_parallel: false, }); + builder.register_handler("delayed_read_tool", Arc::new(DelayedReadTool)); + builder.push_spec(ToolSpec { + name: "delayed_read_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + }); Arc::new(builder.build()) } @@ -847,6 +926,57 @@ mod tests { assert_eq!(results[1].tool_use_id, "r2".to_string()); } + #[tokio::test] + async fn parallel_completion_callback_streams_before_batch_is_done_but_results_stay_ordered() { + let registry = make_registry(); + let runtime = ToolRuntime::new_without_permissions(registry); + let calls = vec![ + ToolCall { + id: "slow".into(), + name: "delayed_read_tool".into(), + input: serde_json::json!({ + "delay_ms": 50, + "output": "slow output", + }), + }, + ToolCall { + id: "fast".into(), + name: "delayed_read_tool".into(), + input: serde_json::json!({ + "delay_ms": 5, + "output": "fast output", + }), + }, + ]; + let completions = Arc::new(std::sync::Mutex::new(Vec::new())); + let completions_clone = Arc::clone(&completions); + + let results = runtime + .execute_batch_streaming_with_completion( + &calls, + |_tool_use_id, _content| {}, + move |result| { + completions_clone + .lock() + .expect("lock completions") + .push(result.tool_use_id.clone()); + }, + ) + .await; + + assert_eq!( + completions.lock().expect("lock completions").as_slice(), + &["fast".to_string(), "slow".to_string()] + ); + assert_eq!( + results + .iter() + .map(|result| result.tool_use_id.as_str()) + .collect::>(), + vec!["slow", "fast"] + ); + } + #[tokio::test] async fn runtime_empty_batch() { let registry = make_registry(); diff --git a/crates/tools/src/tool_summary.rs b/crates/tools/src/tool_summary.rs index aab9e99..290f47a 100644 --- a/crates/tools/src/tool_summary.rs +++ b/crates/tools/src/tool_summary.rs @@ -101,7 +101,6 @@ pub fn tool_summary(name: &str, input: &serde_json::Value, cwd: &Path) -> String let desc = input["description"].as_str().unwrap_or(""); format!("task: {desc}") } - "todowrite" => "todowrite".to_string(), "lsp" => { let path = input["filePath"].as_str().unwrap_or(""); let rel = make_relative(cwd, path); diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 8961058..b4a25df 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -4,16 +4,21 @@ use crate::events::SavedModelEntry; use devo_core::PermissionPreset; use devo_core::PresetModelCatalog; use devo_core::ProviderWireApi; +use devo_protocol::SessionId; /// Summary returned when the interactive TUI exits. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppExit { + /// Active session identifier at exit, when one exists. + pub session_id: Option, /// Total turns completed in the session. pub turn_count: usize, /// Total input tokens accumulated in the session. pub total_input_tokens: usize, /// Total output tokens accumulated in the session. pub total_output_tokens: usize, + /// Total cached input tokens accumulated in the session. + pub total_cache_read_tokens: usize, } /// Public startup request passed from the CLI into the TUI crate. @@ -23,6 +28,8 @@ pub struct AppExit { /// constructing the chat widget's runtime session state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InitialTuiSession { + /// Optional pre-existing session to resume immediately on startup. + pub session_id: Option, /// Model identifier used for the first requests and initial UI projection. pub model: String, /// Provider family used for the initial runtime connection and picker fallback. diff --git a/crates/tui/src/bottom_pane/chat_composer.rs b/crates/tui/src/bottom_pane/chat_composer.rs index 2694b2d..9facf2b 100644 --- a/crates/tui/src/bottom_pane/chat_composer.rs +++ b/crates/tui/src/bottom_pane/chat_composer.rs @@ -1337,6 +1337,9 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } + if Self::is_modified_enter(&key_event) { + return self.handle_input_basic(key_event); + } if key_event.code == KeyCode::Esc { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); if next_mode != self.footer_mode { @@ -1442,6 +1445,12 @@ impl ChatComposer { p } + fn is_modified_enter(key_event: &KeyEvent) -> bool { + key_event.code == KeyCode::Enter + && (key_event.modifiers.contains(KeyModifiers::SHIFT) + || key_event.modifiers.contains(KeyModifiers::CONTROL)) + } + /// Handle non-ASCII character input (often IME) while still supporting paste-burst detection. /// /// This handler exists because non-ASCII input often comes from IMEs, where characters can @@ -1537,6 +1546,9 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } + if Self::is_modified_enter(&key_event) { + return self.handle_input_basic(key_event); + } if key_event.code == KeyCode::Esc { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); if next_mode != self.footer_mode { @@ -1660,6 +1672,9 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } + if Self::is_modified_enter(&key_event) { + return self.handle_input_basic(key_event); + } self.footer_mode = reset_mode_after_activity(self.footer_mode); let ActivePopup::Skill(popup) = &mut self.active_popup else { @@ -2612,6 +2627,7 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } match key_event { + input if Self::is_modified_enter(&input) => self.handle_input_basic(input), KeyEvent { code: KeyCode::Char('d'), modifiers: crossterm::event::KeyModifiers::CONTROL, diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index c9b136c..fef348d 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -4,6 +4,7 @@ use std::time::Duration; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use devo_protocol::user_input::TextElement; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -348,6 +349,7 @@ impl BottomPane { if self.allow_empty_submit && key.code == KeyCode::Enter + && key.modifiers == KeyModifiers::NONE && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.composer.is_empty() { @@ -515,6 +517,43 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn current_text(&self) -> String { + self.composer.current_text() + } + + pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { + self.composer.set_remote_image_urls(urls); + self.request_redraw(); + } + + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); + self.request_redraw(); + } + + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { + self.active_view().is_none() + && !self.is_task_running + && !self.composer.popup_active() + && !self.external_history_active + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.composer.set_esc_backtrack_hint(/*show*/ true); + self.request_redraw(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + self.composer.set_esc_backtrack_hint(/*show*/ false); + self.request_redraw(); + } + #[allow(dead_code)] pub(crate) fn set_status_line(&mut self, status_line: Option>) { if self.composer.set_status_line(status_line) { diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 2825246..5222f4e 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -5,7 +5,9 @@ //! interaction. Protocol thinking choices come from `devo_protocol::thinking` //! through `Model` instead of a TUI-local reasoning enum. +use std::cell::Cell; use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::PathBuf; use std::time::Instant; @@ -55,11 +57,16 @@ use crate::bottom_pane::ModelPickerEntry; use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::bottom_pane::list_selection_view::SelectionItem; use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::events::PlanStep; +use crate::events::PlanStepStatus; use crate::events::SessionListEntry; use crate::events::TextItemKind; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; use crate::events::WorkerEvent; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::ExecCell; +use crate::exec_cell::new_active_exec_command; use crate::exec_cell::truncated_tool_output_preview; use crate::get_git_diff::get_git_diff; use crate::history_cell; @@ -67,6 +74,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::ScrollbackLine; use crate::markdown::append_markdown; +use crate::render::line_utils::prefix_lines; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::startup_header::STARTUP_HEADER_ANIMATION_INTERVAL; @@ -77,7 +85,11 @@ use crate::streaming::controller::StreamController; use crate::theme::ThemeSet; use crate::tool_result_cell::ToolResultCell; use crate::tui::frame_requester::FrameRequester; +use devo_protocol::SessionHistoryItem; +use devo_protocol::SessionHistoryMetadata; +use devo_protocol::SessionPlanStepStatus; use devo_utils::ansi_escape::ansi_escape_line; +use devo_utils::shell_command::parse_command::parse_command; /// Common initialization parameters shared by `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { @@ -142,6 +154,8 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) struct TranscriptOverlayCell { pub(crate) lines: Vec>, pub(crate) is_stream_continuation: bool, + pub(crate) user_message: Option, + pub(crate) is_selected_user: bool, } #[derive(Debug, Clone, PartialEq, Default)] @@ -197,6 +211,7 @@ enum OnboardingStep { struct ResumeBrowserState { sessions: Vec, selection: usize, + scroll_offset: usize, } #[derive(Debug, Clone)] @@ -204,6 +219,7 @@ struct ActiveToolCall { tool_use_id: String, title: String, lines: Vec>, + exec_like: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -343,6 +359,7 @@ pub(crate) struct ChatWidget { pending_model_selection: Option, theme_set: ThemeSet, active_theme_name: String, + resume_browser_last_height: Cell, turn_count: usize, total_input_tokens: usize, total_output_tokens: usize, @@ -350,8 +367,10 @@ pub(crate) struct ChatWidget { prompt_token_estimate: usize, last_query_input_tokens: usize, last_query_total_tokens: usize, + last_plan_progress: Option<(usize, usize)>, queued_count: usize, active_turn_id: Option, + committed_server_assistant_in_turn: bool, pending_approval: Option, permission_preset: devo_protocol::PermissionPreset, busy: bool, @@ -363,6 +382,33 @@ pub(crate) struct ChatWidget { } impl ChatWidget { + fn format_git_diff_result(result: std::io::Result<(bool, String)>) -> String { + match result { + Ok((true, diff_text)) => { + if diff_text.trim().is_empty() { + "No changes detected.".to_string() + } else { + diff_text + } + } + Ok((false, _)) => "`/diff` — _not inside a git repository_".to_string(), + Err(e) => format!("Failed to compute diff: {e}"), + } + } + + pub(crate) fn should_auto_show_git_diff(tool_title: &str, is_error: bool) -> bool { + if is_error { + return false; + } + let lower = tool_title.to_ascii_lowercase(); + lower.contains("write ") + || lower.starts_with("write:") + || lower.contains("edit ") + || lower.starts_with("edit:") + || lower.contains("apply_patch") + || lower.contains("apply patch") + } + fn can_change_configuration(&self) -> bool { !self.busy } @@ -459,24 +505,39 @@ impl ChatWidget { ]) } - fn truncate_display_text(value: &str, max_chars: usize) -> String { + fn truncate_display_text(value: &str, max_width: usize) -> String { + let total_width = unicode_width::UnicodeWidthStr::width(value); + if total_width <= max_width { + return value.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + + let target_width = max_width.saturating_sub(3); let mut rendered = String::new(); - for (count, ch) in value.chars().enumerate() { - if count >= max_chars { + let mut rendered_width = 0usize; + for ch in value.chars() { + let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if rendered_width.saturating_add(ch_width) > target_width { break; } rendered.push(ch); + rendered_width = rendered_width.saturating_add(ch_width); } - if value.chars().count() > max_chars && max_chars > 0 { - let mut truncated = rendered - .chars() - .take(max_chars.saturating_sub(1)) - .collect::(); - truncated.push('…'); - truncated - } else { - rendered + rendered.push_str("..."); + rendered + } + + fn pad_display_text(value: &str, target_width: usize) -> String { + let width = unicode_width::UnicodeWidthStr::width(value); + if width >= target_width { + return value.to_string(); } + format!("{value}{}", " ".repeat(target_width - width)) } fn tool_text_style() -> Style { @@ -715,6 +776,249 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn rebuild_restored_session_history_from_rich_items( + &mut self, + history_items: &[SessionHistoryItem], + loaded_item_count: u64, + session_id: &str, + title: Option<&str>, + ) -> bool { + self.history.clear(); + self.next_history_flush_index = 0; + + if history_items.is_empty() { + self.add_history_entry_without_redraw(Box::new(history_cell::new_info_event( + format!( + "switched to {session_id}; title: {}; loaded items: {loaded_item_count}", + title.unwrap_or("(untitled)") + ), + None, + ))); + self.frame_requester.schedule_frame(); + return false; + } + + let mut paired_result_by_call_id = HashMap::new(); + for (index, item) in history_items.iter().enumerate() { + if matches!( + item.kind, + devo_protocol::SessionHistoryItemKind::ToolResult + | devo_protocol::SessionHistoryItemKind::Error + ) && let Some(tool_call_id) = item.tool_call_id.as_deref() + { + paired_result_by_call_id + .entry(tool_call_id.to_string()) + .or_insert(index); + } + } + + let metadata_owned_ids: HashSet = history_items + .iter() + .filter_map(|item| { + item.tool_call_id + .clone() + .filter(|_| item.metadata.is_some()) + }) + .collect(); + let mut consumed_indexes = HashSet::new(); + + for (index, item) in history_items.iter().enumerate() { + if consumed_indexes.contains(&index) { + continue; + } + + if let Some(metadata) = &item.metadata { + if let Some(tool_call_id) = item.tool_call_id.as_deref() + && let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() + { + consumed_indexes.insert(result_index); + } + match metadata { + SessionHistoryMetadata::PlanUpdate { explanation, steps } => { + self.on_plan_updated( + explanation.clone(), + steps + .iter() + .map(|step| crate::events::PlanStep { + text: step.text.clone(), + status: match step.status { + SessionPlanStepStatus::Pending => { + crate::events::PlanStepStatus::Pending + } + SessionPlanStepStatus::InProgress => { + crate::events::PlanStepStatus::InProgress + } + SessionPlanStepStatus::Completed => { + crate::events::PlanStepStatus::Completed + } + SessionPlanStepStatus::Cancelled => { + crate::events::PlanStepStatus::Cancelled + } + }, + }) + .collect(), + ); + } + SessionHistoryMetadata::Edited { changes } => { + self.add_history_entry_without_redraw(Box::new( + history_cell::new_patch_event(changes.clone(), &self.session.cwd), + )); + } + SessionHistoryMetadata::Explored { actions } => { + self.restore_explored_history_item(item, actions.clone()); + } + } + continue; + } + + if let Some(changes) = Self::edited_changes_from_history_item(item) { + self.add_history_entry_without_redraw(Box::new(history_cell::new_patch_event( + changes, + &self.session.cwd, + ))); + continue; + } + + if item.kind == devo_protocol::SessionHistoryItemKind::ToolCall + && let Some(tool_call_id) = item.tool_call_id.as_deref() + { + if metadata_owned_ids.contains(tool_call_id) { + continue; + } + if let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() { + consumed_indexes.insert(result_index); + let result_item = &history_items[result_index]; + let title_line = + (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)); + self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( + title_line, + result_item.body.clone(), + Self::tool_dot_prefix(), + Line::from(" "), + Self::tool_text_style(), + false, + ))); + continue; + } + } + + match item.kind { + devo_protocol::SessionHistoryItemKind::User => { + self.add_history_entry_without_redraw(Box::new(history_cell::new_user_prompt( + item.body.clone(), + Vec::new(), + Vec::new(), + Vec::new(), + self.active_accent_color(), + ))); + } + devo_protocol::SessionHistoryItemKind::Assistant => { + self.add_markdown_history_without_redraw("Assistant", &item.body); + } + devo_protocol::SessionHistoryItemKind::Reasoning => { + self.add_markdown_history_without_redraw("Reasoning", &item.body); + } + devo_protocol::SessionHistoryItemKind::ToolCall => { + self.add_history_entry_without_redraw(Box::new( + history_cell::AgentMessageCell::new_with_prefix( + vec![Self::running_tool_line(&item.title)], + self.dot_prefix(DotStatus::Pending), + " ", + false, + ), + )); + } + devo_protocol::SessionHistoryItemKind::ToolResult + | devo_protocol::SessionHistoryItemKind::CommandExecution => { + self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( + (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)), + item.body.clone(), + Self::tool_dot_prefix(), + Line::from(" "), + Self::tool_text_style(), + false, + ))); + } + devo_protocol::SessionHistoryItemKind::Error => { + self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( + (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)), + item.body.clone(), + self.failed_dot_prefix(), + Line::from(" "), + Self::tool_text_style(), + false, + ))); + } + devo_protocol::SessionHistoryItemKind::TurnSummary => { + self.add_history_entry_without_redraw(Box::new( + history_cell::TurnSummaryCell::new( + item.title.clone(), + item.duration_ms, + self.active_accent_color(), + ), + )); + } + } + } + + self.frame_requester.schedule_frame(); + true + } + + fn edited_changes_from_history_item( + item: &SessionHistoryItem, + ) -> Option> { + if item.kind != devo_protocol::SessionHistoryItemKind::ToolResult { + return None; + } + let lower_title = item.title.to_ascii_lowercase(); + if !lower_title.contains("apply_patch") + && !lower_title.contains("write") + && !item.body.contains("\"files\"") + { + return None; + } + let value: serde_json::Value = serde_json::from_str(&item.body).ok()?; + let files = value.get("files")?.as_array()?; + let diff = value + .get("diff") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let mut changes = HashMap::new(); + for file in files { + let path = PathBuf::from(file.get("path")?.as_str()?); + let kind = file.get("kind")?.as_str()?; + let additions = file + .get("additions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let deletions = file + .get("deletions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let change = match kind { + "add" => devo_protocol::protocol::FileChange::Add { + content: "\n".repeat(additions as usize), + }, + "delete" => devo_protocol::protocol::FileChange::Delete { + content: "\n".repeat(deletions as usize), + }, + "update" | "move" => devo_protocol::protocol::FileChange::Update { + unified_diff: diff.clone(), + move_path: file + .get("movePath") + .or_else(|| file.get("move_path")) + .and_then(serde_json::Value::as_str) + .map(PathBuf::from), + }, + _ => continue, + }; + changes.insert(path, change); + } + (!changes.is_empty()).then_some(changes) + } + fn clear_for_session_switch(&mut self) { self.history.clear(); self.next_history_flush_index = 0; @@ -828,6 +1132,7 @@ impl ChatWidget { pending_model_selection: None, theme_set, active_theme_name, + resume_browser_last_height: Cell::new(0), turn_count: 0, total_input_tokens: 0, total_output_tokens: 0, @@ -835,8 +1140,10 @@ impl ChatWidget { prompt_token_estimate: 0, last_query_input_tokens: 0, last_query_total_tokens: 0, + last_plan_progress: None, queued_count: 0, active_turn_id: None, + committed_server_assistant_in_turn: false, pending_approval: None, permission_preset: initial_permission_preset, busy: false, @@ -861,6 +1168,18 @@ impl ChatWidget { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } + if self.resume_browser_loading { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.resume_browser = None; + self.resume_browser_loading = false; + self.set_status_message("Ready"); + self.frame_requester.schedule_frame(); + } + _ => {} + } + return; + } if self.resume_browser.is_some() { self.handle_resume_browser_key_event(key); return; @@ -1213,6 +1532,7 @@ impl ChatWidget { pub(crate) fn handle_worker_event(&mut self, event: WorkerEvent) { match event { + WorkerEvent::SessionActivated { .. } => {} WorkerEvent::TurnStarted { model, thinking, @@ -1221,6 +1541,7 @@ impl ChatWidget { .. } => { self.active_turn_id = Some(turn_id); + self.committed_server_assistant_in_turn = false; self.update_session_request_model(model); self.thinking_selection = thinking; self.session.reasoning_effort = reasoning_effort; @@ -1231,6 +1552,7 @@ impl ChatWidget { self.bottom_pane.set_task_running(true); } WorkerEvent::TextItemStarted { item_id, kind } => { + self.flush_active_cell(); self.start_text_item(ActiveTextItemId::Server(item_id), kind); self.set_status_message(match kind { TextItemKind::Assistant => "Generating", @@ -1261,6 +1583,7 @@ impl ChatWidget { } WorkerEvent::TextDelta(text) => { if !self.has_server_active_item(TextItemKind::Assistant) { + self.flush_active_cell(); self.push_text_item_delta( ActiveTextItemId::Legacy(TextItemKind::Assistant), TextItemKind::Assistant, @@ -1271,6 +1594,7 @@ impl ChatWidget { } WorkerEvent::ReasoningDelta(text) => { if !self.has_server_active_item(TextItemKind::Reasoning) { + self.flush_active_cell(); self.push_text_item_delta( ActiveTextItemId::Legacy(TextItemKind::Reasoning), TextItemKind::Reasoning, @@ -1280,7 +1604,13 @@ impl ChatWidget { self.set_status_message("Thinking"); } WorkerEvent::AssistantMessageCompleted(text) => { - if !self.has_server_active_item(TextItemKind::Assistant) { + if !self.committed_server_assistant_in_turn + && !self.has_server_active_item(TextItemKind::Assistant) + && !self + .active_text_items + .iter() + .any(|item| item.kind == TextItemKind::Assistant) + { self.complete_text_item( ActiveTextItemId::Legacy(TextItemKind::Assistant), TextItemKind::Assistant, @@ -1302,12 +1632,76 @@ impl ChatWidget { WorkerEvent::ToolCall { tool_use_id, summary, + parsed_commands, } => { + let command = crate::exec_command::split_command_string(&summary); + let parsed = parsed_commands.unwrap_or_else(|| parse_command(&command)); + let exec_like = !parsed.is_empty() + && parsed.iter().all(|parsed| { + !matches!( + parsed, + devo_protocol::parse_command::ParsedCommand::Unknown { .. } + ) + }); + if exec_like { + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && let Some(grouped) = cell.with_added_call( + tool_use_id.clone(), + command.clone(), + parsed.clone(), + devo_protocol::protocol::ExecCommandSource::Agent, + None, + ) + { + *cell = grouped; + self.active_tool_calls.insert( + tool_use_id.clone(), + ActiveToolCall { + tool_use_id, + title: summary, + lines: Vec::new(), + exec_like: true, + }, + ); + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + self.set_status_message("Tool started"); + return; + } + + self.flush_active_cell(); + self.active_cell = Some(Box::new(new_active_exec_command( + tool_use_id.clone(), + command, + parsed, + devo_protocol::protocol::ExecCommandSource::Agent, + None, + true, + ))); + self.active_tool_calls.insert( + tool_use_id.clone(), + ActiveToolCall { + tool_use_id, + title: summary, + lines: Vec::new(), + exec_like: true, + }, + ); + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + self.set_status_message("Tool started"); + return; + } + let title = 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, }; self.active_tool_calls .insert(tool_use_id.clone(), tool_call.clone()); @@ -1325,6 +1719,18 @@ impl ChatWidget { } WorkerEvent::ToolOutputDelta { tool_use_id, delta } => { if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) { + if tool_call.exec_like { + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.append_output(&tool_use_id, &delta) + { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + } + return; + } let line = Line::from(delta.clone()).patch_style(Self::tool_text_style()); tool_call.lines.push(line); self.active_cell_revision = self.active_cell_revision.wrapping_add(1); @@ -1351,12 +1757,69 @@ impl ChatWidget { } else { DotStatus::Completed }; - let resolved_title = self - .active_tool_calls - .remove(&tool_use_id) - .map(|tool| tool.title) - .filter(|tool_title| !tool_title.is_empty()) - .unwrap_or(title); + let resolved_title = + self.active_tool_calls + .remove(&tool_use_id) + .unwrap_or(ActiveToolCall { + tool_use_id: tool_use_id.clone(), + title, + lines: Vec::new(), + exec_like: false, + }); + + if resolved_title.exec_like { + let output = CommandOutput { + exit_code: if is_error { 1 } else { 0 }, + aggregated_output: preview.clone(), + formatted_output: preview.clone(), + }; + let duration = std::time::Duration::from_millis(0); + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + let completed = cell.complete_call(&tool_use_id, output.clone(), duration); + if completed { + if cell.is_exploring_cell() { + self.active_cell_revision = + self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + } else if cell.should_flush() { + self.flush_active_cell(); + } else { + self.active_cell_revision = + self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + } + self.set_status_message(if is_error { + "Tool returned an error" + } else { + "Tool completed" + }); + return; + } + } + if let Some(cell) = self.history.iter_mut().rev().find_map(|cell| { + cell.as_any_mut() + .downcast_mut::() + .and_then(|cell| { + cell.complete_call(&tool_use_id, output.clone(), duration) + .then_some(cell) + }) + }) { + let _ = cell; + self.frame_requester.schedule_frame(); + self.set_status_message(if is_error { + "Tool returned an error" + } else { + "Tool completed" + }); + return; + } + } + + let resolved_title = resolved_title.title; let title_line = (!resolved_title.is_empty()).then(|| Self::ran_tool_line(&resolved_title)); @@ -1376,6 +1839,21 @@ impl ChatWidget { } else { "Tool completed" }); + if Self::should_auto_show_git_diff(&resolved_title, is_error) { + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = Self::format_git_diff_result(get_git_diff().await); + tx.send(AppEvent::DiffResult(text)); + }); + } + } + WorkerEvent::PlanUpdated { explanation, steps } => { + self.on_plan_updated(explanation, steps); + self.set_status_message("Plan updated"); + } + WorkerEvent::PatchApplied { changes } => { + self.add_to_history(history_cell::new_patch_event(changes, &self.session.cwd)); + self.set_status_message("Patch applied"); } WorkerEvent::ApprovalRequest { session_id, @@ -1459,6 +1937,7 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.pending_approval = None; + self.committed_server_assistant_in_turn = false; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1504,6 +1983,7 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.pending_approval = None; + self.committed_server_assistant_in_turn = false; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1578,6 +2058,7 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.active_text_items.clear(); + self.committed_server_assistant_in_turn = false; self.stream_chunking_policy.reset(); self.busy = false; self.turn_count = 0; @@ -1608,6 +2089,7 @@ impl ChatWidget { last_query_input_tokens, prompt_token_estimate, history_items, + rich_history_items, loaded_item_count, pending_texts, } => { @@ -1621,6 +2103,7 @@ impl ChatWidget { self.history.clear(); self.next_history_flush_index = 0; self.active_text_items.clear(); + self.committed_server_assistant_in_turn = false; self.stream_chunking_policy.reset(); self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; @@ -1628,12 +2111,19 @@ 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.rebuild_restored_session_history( - history_items, + if !self.rebuild_restored_session_history_from_rich_items( + &rich_history_items, loaded_item_count, &session_id, title.as_deref(), - ); + ) { + self.rebuild_restored_session_history( + history_items, + loaded_item_count, + &session_id, + title.as_deref(), + ); + } // Restore pending queue state from the resumed session self.queued_count = pending_texts.len(); self.bottom_pane.clear_pending_cells(); @@ -1702,6 +2192,83 @@ impl ChatWidget { } } + fn on_plan_updated(&mut self, explanation: Option, steps: Vec) { + let total = steps.len(); + let completed = steps + .iter() + .filter(|step| matches!(step.status, PlanStepStatus::Completed)) + .count(); + self.last_plan_progress = (total > 0).then_some((completed, total)); + + let mut lines = vec![Line::from(vec![ + Span::styled("▌", Style::default().fg(Color::Rgb(120, 220, 160))), + " ".into(), + "Updated Plan".bold(), + ])]; + if let Some(explanation) = explanation + && !explanation.trim().is_empty() + { + lines.push(Line::from("")); + lines.push(Line::from(explanation.italic())); + lines.push(Line::from("")); + } + for step in steps { + let (prefix, style) = match step.status { + PlanStepStatus::Completed => ("✔ ", Style::default().green()), + PlanStepStatus::InProgress => ("→ ", Style::default().cyan()), + PlanStepStatus::Pending => ("□ ", Style::default().dim()), + PlanStepStatus::Cancelled => ("✗ ", Style::default().red()), + }; + lines.extend(prefix_lines( + vec![Line::from(Span::styled(step.text, style))], + Span::styled(format!(" {prefix}"), style), + Span::from(" "), + )); + } + if !lines.is_empty() { + self.add_to_history(PlainHistoryCell::new(lines)); + } + self.frame_requester.schedule_frame(); + } + + fn restore_explored_history_item( + &mut self, + item: &SessionHistoryItem, + actions: Vec, + ) { + let command = item.title.clone(); + let command_tokens = crate::exec_command::split_command_string(&command); + if let Some(cell) = self + .history + .last_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && let Some(grouped) = cell.with_added_call( + item.tool_call_id + .clone() + .unwrap_or_else(|| "restored".to_string()), + command_tokens.clone(), + actions.clone(), + devo_protocol::protocol::ExecCommandSource::Agent, + None, + ) + { + *cell = grouped; + return; + } + + let exec = new_active_exec_command( + item.tool_call_id + .clone() + .unwrap_or_else(|| "restored".to_string()), + command_tokens, + actions, + devo_protocol::protocol::ExecCommandSource::Agent, + None, + false, + ); + self.add_history_entry_without_redraw(Box::new(exec)); + } + pub(crate) fn submit_text(&mut self, text: String) { self.submit_user_message(UserMessage::from(text)); } @@ -1844,16 +2411,7 @@ impl ChatWidget { self.set_status_message("Computing diff"); let tx = self.app_event_tx.clone(); tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; + let text = Self::format_git_diff_result(get_git_diff().await); tx.send(AppEvent::DiffResult(text)); }); } @@ -2221,6 +2779,9 @@ impl ChatWidget { } self.sync_text_item_cell(index); self.commit_completed_text_items(); + if matches!(item_id, ActiveTextItemId::Server(_)) && kind == TextItemKind::Assistant { + self.committed_server_assistant_in_turn = true; + } } fn ensure_text_item(&mut self, item_id: ActiveTextItemId, kind: TextItemKind) -> usize { @@ -2473,7 +3034,8 @@ impl ChatWidget { fn tool_preview_lines(&self, preview: &str) -> Vec> { let width = self.last_known_width().saturating_sub(2).max(1); - let mut preview_lines = truncated_tool_output_preview(preview, width, 2); + let mut preview_lines = + truncated_tool_output_preview(preview, width, 2, crate::exec_cell::TOOL_CALL_MAX_LINES); for line in &mut preview_lines { line.spans = line .spans @@ -2736,6 +3298,16 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn flush_active_cell(&mut self) { + if let Some(active) = self.active_cell.take() { + self.add_history_entry_without_redraw(active); + } + } + + fn bump_active_cell_revision(&mut self) { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + } + /// Pop the oldest pending cell from the bottom pane and add it to history /// as a normal user input cell. fn unqueue_oldest_pending(&mut self) { @@ -2771,6 +3343,14 @@ impl ChatWidget { .unwrap_or_default() } + #[cfg(test)] + pub(crate) fn active_cell_display_lines_for_test(&self, width: u16) -> Vec> { + self.active_cell + .as_ref() + .map(|cell| cell.display_lines(width)) + .unwrap_or_default() + } + pub(crate) fn transcript_overlay_cell_count(&self) -> usize { self.history.len() } @@ -2779,13 +3359,59 @@ impl ChatWidget { let width = width.max(1); self.history .iter() - .map(|cell| TranscriptOverlayCell { - lines: cell.transcript_lines(width), - is_stream_continuation: cell.is_stream_continuation(), + .map(|cell| { + let user_message = cell + .as_any() + .downcast_ref::() + .map(|user| UserMessage { + text: user.message.clone(), + local_images: user + .local_image_paths + .iter() + .cloned() + .map(|path| crate::bottom_pane::LocalImageAttachment { + path, + placeholder: String::new(), + }) + .collect(), + remote_image_urls: user.remote_image_urls.clone(), + text_elements: user.text_elements.clone(), + mention_bindings: Vec::new(), + }); + TranscriptOverlayCell { + lines: cell.transcript_lines(width), + is_stream_continuation: cell.is_stream_continuation(), + user_message, + is_selected_user: false, + } }) .collect() } + pub(crate) fn truncate_history_to_user_turn_count(&mut self, user_turn_count: usize) { + let mut remaining_users = user_turn_count; + let mut new_len = 0usize; + for (idx, cell) in self.history.iter().enumerate() { + let is_user = cell + .as_ref() + .as_any() + .downcast_ref::() + .is_some(); + if is_user { + if remaining_users == 0 { + break; + } + remaining_users -= 1; + } + new_len = idx + 1; + } + self.history.truncate(new_len); + self.next_history_flush_index = self.next_history_flush_index.min(self.history.len()); + self.refresh_user_cell_indices(); + self.exit_selection_mode(); + self.frame_requester.schedule_frame(); + } + pub(crate) fn transcript_overlay_live_tail_key(&self) -> Option { if !self.transcript_overlay_has_live_tail() { return None; @@ -2838,6 +3464,22 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { + self.bottom_pane + .set_remote_image_urls(user_message.remote_image_urls); + let local_image_paths = user_message + .local_images + .into_iter() + .map(|attachment| attachment.path) + .collect::>(); + self.bottom_pane.set_text_content( + user_message.text, + user_message.text_elements, + local_image_paths, + ); + self.set_status_message("Previous message loaded"); + } + pub(crate) fn pop_next_queued_user_message(&mut self) -> Option { self.queued_user_messages.pop_front() } @@ -2848,12 +3490,36 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + #[cfg(test)] + pub(crate) fn last_plan_progress_for_test(&self) -> Option<(usize, usize)> { + self.last_plan_progress + } + pub(crate) fn active_viewport_lines_for_test(&self, width: u16) -> Vec> { self.active_viewport_lines(width) } + pub(crate) fn composer_is_empty(&self) -> bool { + self.bottom_pane.current_text().trim().is_empty() + } + + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { + self.bottom_pane.is_normal_backtrack_mode() + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.bottom_pane.show_esc_backtrack_hint(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + self.bottom_pane.clear_esc_backtrack_hint(); + } + fn active_viewport_lines(&self, width: u16) -> Vec> { let mut lines = Vec::new(); + if let Some(cell) = &self.active_cell { + Self::extend_lines_with_separator(&mut lines, cell.display_lines(width)); + } for item in &self.active_text_items { if let Some(cell) = &item.cell { Self::extend_lines_with_separator(&mut lines, cell.display_lines(width)); @@ -3180,6 +3846,7 @@ impl ChatWidget { self.resume_browser = Some(ResumeBrowserState { sessions, selection, + scroll_offset: 0, }); self.set_status_message("Resume session"); } @@ -3191,28 +3858,69 @@ impl ChatWidget { let Some(browser) = self.resume_browser.as_mut() else { return; }; + let page_step = Self::resume_browser_visible_capacity( + self.resume_browser_last_height.get(), + !browser.sessions.is_empty(), + ) + .max(1); match key.code { - KeyCode::Esc => { + KeyCode::Esc | KeyCode::Char('q') => { self.resume_browser = None; self.resume_browser_loading = false; self.set_status_message("Ready"); + self.frame_requester.schedule_frame(); } KeyCode::Up => { if browser.sessions.is_empty() { browser.selection = 0; - } else { - browser.selection = (browser.selection as isize - 1) - .rem_euclid(browser.sessions.len() as isize) - as usize; + } else if browser.selection > 0 { + browser.selection -= 1; } + self.ensure_resume_selection_visible(u16::MAX); self.frame_requester.schedule_frame(); } KeyCode::Down => { + if browser.sessions.is_empty() { + browser.selection = 0; + } else if browser.selection + 1 < browser.sessions.len() { + browser.selection += 1; + } + self.ensure_resume_selection_visible(u16::MAX); + self.frame_requester.schedule_frame(); + } + KeyCode::PageUp => { + if browser.sessions.is_empty() { + browser.selection = 0; + } else { + browser.selection = browser.selection.saturating_sub(page_step); + } + self.ensure_resume_selection_visible(u16::MAX); + self.frame_requester.schedule_frame(); + } + KeyCode::PageDown => { + if browser.sessions.is_empty() { + browser.selection = 0; + } else { + browser.selection = browser + .selection + .saturating_add(page_step) + .min(browser.sessions.len().saturating_sub(1)); + } + self.ensure_resume_selection_visible(u16::MAX); + self.frame_requester.schedule_frame(); + } + KeyCode::Home => { + browser.selection = 0; + self.ensure_resume_selection_visible(u16::MAX); + self.frame_requester.schedule_frame(); + } + KeyCode::End => { if browser.sessions.is_empty() { browser.selection = 0; } else { - browser.selection = (browser.selection + 1) % browser.sessions.len(); + browser.selection = browser.sessions.len().saturating_sub(1); } + self.ensure_resume_selection_visible(u16::MAX); self.frame_requester.schedule_frame(); } KeyCode::Enter => { @@ -3231,6 +3939,146 @@ impl ChatWidget { pub(crate) fn is_resume_browser_open(&self) -> bool { self.resume_browser_loading || self.resume_browser.is_some() } + + 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_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_window( + sessions_len: usize, + selection: usize, + requested_offset: usize, + area_height: u16, + ) -> (usize, usize, bool, bool) { + if sessions_len == 0 { + return (0, 0, false, false); + } + let list_window = Self::resume_browser_visible_capacity(area_height, true); + if list_window == 0 { + return (selection.min(sessions_len.saturating_sub(1)), 0, true, true); + } + + let selection = selection.min(sessions_len.saturating_sub(1)); + let mut start = requested_offset.min(sessions_len.saturating_sub(1)); + let mut slots = list_window; + + loop { + if slots == 0 { + return (selection, 0, start > 0, selection + 1 < sessions_len); + } + let end = (start + slots).min(sessions_len); + let has_above = start > 0; + let has_below = end < sessions_len; + let indicator_rows = usize::from(has_above) + usize::from(has_below); + let session_slots = list_window.saturating_sub(indicator_rows); + if session_slots == slots { + let end = (start + session_slots).min(sessions_len); + let has_above = start > 0; + let has_below = end < sessions_len; + return (start, end, has_above, has_below); + } + slots = session_slots; + if selection < start { + start = selection; + } else if selection >= start + slots { + start = selection + 1 - slots; + } + start = start.min(sessions_len.saturating_sub(slots.max(1))); + } + } + + fn resume_browser_footer_lines(has_sessions: bool) -> Vec> { + if has_sessions { + vec![ + Line::from("↑/↓ select pgup/pgdn page home/end jump".dim()), + Line::from("enter resume q back".dim()), + ] + } else { + vec![Line::from("q back".dim())] + } + } + + fn resume_browser_progress_label( + selection: usize, + sessions_len: usize, + rendered_start: usize, + area_height: u16, + ) -> 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 max_scroll = sessions_len.saturating_sub(capacity.max(1)); + let percent = if max_scroll == 0 { + 100 + } else { + ((rendered_start.min(max_scroll) as f32 / max_scroll as f32) * 100.0).round() as usize + }; + format!(" {position} / {total} · {percent}% ") + } + + fn ensure_resume_selection_visible(&mut self, area_height: u16) { + let Some(browser) = self.resume_browser.as_mut() else { + return; + }; + if browser.sessions.is_empty() { + browser.selection = 0; + browser.scroll_offset = 0; + return; + } + + let selection = browser + .selection + .min(browser.sessions.len().saturating_sub(1)); + browser.selection = selection; + let capacity = Self::resume_browser_visible_capacity(area_height, true); + if capacity == 0 { + browser.scroll_offset = selection; + return; + } + + if selection < browser.scroll_offset { + browser.scroll_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 max_offset = browser.sessions.len().saturating_sub(capacity); + browser.scroll_offset = browser.scroll_offset.min(max_offset); + } + + #[cfg(test)] + pub(crate) fn resume_browser_selection_for_test(&self) -> Option { + self.resume_browser + .as_ref() + .map(|browser| browser.selection) + } + + #[cfg(test)] + pub(crate) fn resume_browser_scroll_offset_for_test(&self) -> Option { + self.resume_browser + .as_ref() + .map(|browser| browser.scroll_offset) + } + + #[cfg(test)] + pub(crate) fn open_resume_browser_for_test(&mut self, sessions: Vec) { + self.open_resume_browser(sessions); + } } impl Renderable for ChatWidget { @@ -3250,26 +4098,38 @@ impl Renderable for ChatWidget { } if let Some(browser) = &self.resume_browser { + self.resume_browser_last_height.set(area.height); Block::default().style(Style::default()).render(area, buf); + let (scroll_offset, end, has_above, has_below) = Self::resume_browser_window( + browser.sessions.len(), + browser.selection, + browser.scroll_offset, + area.height, + ); let title_width = browser .sessions .iter() - .map(|session| session.title.chars().count()) + .map(|session| unicode_width::UnicodeWidthStr::width(session.title.as_str())) .max() .unwrap_or(5) - .clamp(5, 36); - let mut lines = vec![ - Line::from("Resume Session".bold()), - Line::from("Use Up/Down to select a session, Enter to resume.".dim()), - Line::from("Esc to go back.".dim()), - Line::from(""), - ]; + .clamp(5, 48); + let progress = Self::resume_browser_progress_label( + browser.selection, + browser.sessions.len(), + scroll_offset, + area.height, + ); + let mut lines = vec![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())); } else { lines.push( Line::from(format!( - " {:title_width$} {:<16} {}", + " {:title_width$} {:<36} {}", "Title", "Session ID", "Updated", @@ -3281,22 +4141,30 @@ impl Renderable for ChatWidget { Line::from(format!( " {} {} {}", "-".repeat(title_width), - "-".repeat(16), - "-".repeat(19) + "-".repeat(36), + "-".repeat(23) )) .dim(), ); - for (index, session) in browser.sessions.iter().enumerate() { + if has_above { + lines.push(Line::from(" ↑ more").dim()); + } + for (index, session) in browser + .sessions + .iter() + .enumerate() + .skip(scroll_offset) + .take(end.saturating_sub(scroll_offset)) + { let marker = if index == browser.selection { ">" } else { " " }; let current = if session.is_active { " current" } else { "" }; - let display_title = Self::truncate_display_text(&session.title, title_width); + let display_title = Self::pad_display_text( + &Self::truncate_display_text(&session.title, title_width), + title_width, + ); let line = format!( - "{marker} {:title_width$} {:<16} {}{}", - display_title, - session.session_id, - session.updated_at, - current, - title_width = title_width + "{marker} {} {:<16} {}{}", + display_title, session.session_id, session.updated_at, current ); lines.push(if index == browser.selection { Line::from(line).bold() @@ -3304,7 +4172,13 @@ impl Renderable for ChatWidget { Line::from(line) }); } + if has_below { + lines.push(Line::from(" ↓ more").dim()); + } } + lines.extend(Self::resume_browser_footer_lines( + !browser.sessions.is_empty(), + )); Paragraph::new(Text::from(lines)) .block(Block::default().title("Devo Sessions")) .wrap(Wrap { trim: false }) diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 9b0ed47..32ac107 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -15,6 +15,7 @@ use devo_protocol::SessionId; use devo_protocol::ThinkingCapability; use devo_protocol::TurnId; use pretty_assertions::assert_eq; +use ratatui::text::Line; use tokio::sync::mpsc; use crate::app_command::AppCommand; @@ -24,6 +25,8 @@ use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::ThinkingListEntry; use crate::chatwidget::TuiSessionState; +use crate::events::PlanStep; +use crate::events::PlanStepStatus; use crate::render::renderable::Renderable; use crate::slash_command::built_in_slash_commands; use crate::tui::frame_requester::FrameRequester; @@ -137,6 +140,18 @@ fn trim_trailing_blank_scrollback_lines( lines } +fn line_texts(lines: Vec>) -> Vec { + lines + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect() +} + fn indices_containing(lines: &[String], needles: &[&str]) -> Vec { needles .iter() @@ -150,7 +165,7 @@ fn indices_containing(lines: &[String], needles: &[&str]) -> Vec { } #[test] -fn resume_command_opens_loading_browser_immediately() { +fn user_prompt_multiline_has_no_extra_blank_prefix_rows_and_consistent_prefix_text() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), @@ -158,894 +173,922 @@ fn resume_command_opens_loading_browser_immediately() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_app_event(AppEvent::Command(AppCommand::RunUserShellCommand { - command: "session list".to_string(), - })); + widget.submit_text("line one\nline two\nline three".to_string()); - assert!(widget.is_resume_browser_open()); + let transcript = line_texts(widget.transcript_overlay_lines(80)); + let user_lines: Vec = transcript + .into_iter() + .filter(|line| line.starts_with("▌ ")) + .collect(); - let rows = rendered_rows(&widget, 80, 12); - assert!( - rows.iter() - .any(|row| row.contains("Loading saved sessions")) + assert_eq!( + user_lines.len(), + 5, + "unexpected user prompt rows: {user_lines:?}" ); + assert_eq!(user_lines[0], "▌ "); + assert_eq!(user_lines[1], "▌ line one"); + assert_eq!(user_lines[2], "▌ line two"); + assert_eq!(user_lines[3], "▌ line three"); + assert_eq!(user_lines[4], "▌ "); } #[test] -fn approval_request_renders_bottom_pane_menu_and_accepts_once() { +fn restore_user_message_to_composer_restores_text() { 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 session_id = SessionId::new(); - let turn_id = TurnId::new(); - - widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { - session_id, - turn_id, - approval_id: "approval-call-1".to_string(), - action_summary: "write src/main.rs".to_string(), - justification: "Tool execution requires approval.".to_string(), - resource: Some("FileWrite".to_string()), - available_scopes: vec!["once".to_string(), "session".to_string()], - path: Some("src/main.rs".to_string()), - host: None, - target: None, - }); - - let scrollback = widget.drain_scrollback_lines(80); - assert!(!scrollback_contains_text( - &scrollback, - "Permission required" - )); - - let rendered = rendered_rows(&widget, 80, 16).join("\n"); - assert!(rendered.contains("Permission approval required")); - assert!(rendered.contains("Approve once")); - assert!(rendered.contains("Approve for session")); - assert!(rendered.contains("Deny")); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + widget + .restore_user_message_to_composer(crate::chatwidget::UserMessage::from("previous message")); - let event = app_event_rx.try_recv().expect("approval response event"); - assert_eq!( - event, - AppEvent::Command(AppCommand::ApprovalRespond { - session_id, - turn_id, - approval_id: "approval-call-1".to_string(), - decision: ApprovalDecisionValue::Approve, - scope: ApprovalScopeValue::Once, - }) + let rendered = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + rendered.contains("previous message"), + "composer should show restored text:\n{rendered}" ); } #[test] -fn approval_request_bottom_pane_menu_denies_with_n_shortcut() { +fn transcript_overlay_cell_carries_user_message_payload() { 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 session_id = SessionId::new(); - let turn_id = TurnId::new(); - - widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { - session_id, - turn_id, - approval_id: "approval-call-2".to_string(), - action_summary: "run shell command".to_string(), - justification: "Tool execution requires approval.".to_string(), - resource: Some("ShellExec".to_string()), - available_scopes: vec!["once".to_string()], - path: None, - host: None, - target: Some("cargo test".to_string()), - }); - - let rendered = rendered_rows(&widget, 80, 16).join("\n"); - assert!(rendered.contains("Permission approval required")); - assert!(rendered.contains("run shell command")); - assert!(rendered.contains("Deny")); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + widget.submit_text("previous message".to_string()); + let _ = widget.drain_scrollback_lines(80); - let event = app_event_rx.try_recv().expect("approval response event"); + let cells = widget.transcript_overlay_cells(80); + let user_cell = cells + .into_iter() + .find(|cell| cell.user_message.is_some()) + .expect("user transcript cell"); assert_eq!( - event, - AppEvent::Command(AppCommand::ApprovalRespond { - session_id, - turn_id, - approval_id: "approval-call-2".to_string(), - decision: ApprovalDecisionValue::Deny, - scope: ApprovalScopeValue::Once, - }) + user_cell.user_message.expect("user payload").text, + "previous message" ); } #[test] -fn submitted_prompt_requests_on_request_approval_policy() { +fn backtrack_preview_restore_latest_user_message() { 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 (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.submit_text("please edit a file".to_string()); + widget.submit_text("first message".to_string()); + let _ = widget.drain_scrollback_lines(80); + widget.submit_text("second message".to_string()); + let _ = widget.drain_scrollback_lines(80); - let event = app_event_rx.try_recv().expect("user turn event"); - let AppEvent::Command(AppCommand::UserTurn { - approval_policy, .. - }) = event - else { - panic!("expected user turn command"); + let mut overlay = + crate::pager_overlay::Overlay::new_transcript(widget.transcript_overlay_cells(80), 80); + let crate::pager_overlay::Overlay::Transcript(transcript) = &mut overlay else { + panic!("expected transcript overlay"); }; - assert_eq!(approval_policy, Some("on-request".to_string())); + transcript.begin_backtrack_preview(); + let selected = transcript + .selected_user_message() + .expect("selected latest user"); + widget.restore_user_message_to_composer(selected); + + let rendered = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + rendered.contains("second message"), + "expected latest message to be restored into composer:\n{rendered}" + ); } #[test] -fn permissions_command_opens_bottom_pane_picker_and_updates_default() { +fn backtrack_preview_can_restore_previous_and_next_user_messages() { 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_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: TurnId::new(), - }); - - widget.handle_app_event(AppEvent::RunSlashCommand { - command: "permissions".to_string(), - }); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - let rendered = rendered_rows(&widget, 100, 18).join("\n"); - assert!(rendered.contains("Update Model Permissions")); - assert!(rendered.contains("Read Only")); - assert!(rendered.contains("Default (current)")); - assert!(rendered.contains("Auto-review")); - assert!(rendered.contains("Full Access")); + widget.submit_text("first message".to_string()); + let _ = widget.drain_scrollback_lines(80); + widget.submit_text("second message".to_string()); + let _ = widget.drain_scrollback_lines(80); - widget.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + let mut overlay = + crate::pager_overlay::Overlay::new_transcript(widget.transcript_overlay_cells(80), 80); + let crate::pager_overlay::Overlay::Transcript(transcript) = &mut overlay else { + panic!("expected transcript overlay"); + }; + transcript.begin_backtrack_preview(); + transcript.select_prev_user(); + let previous = transcript + .selected_user_message() + .expect("selected previous user"); + widget.restore_user_message_to_composer(previous); + let rendered_prev = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + rendered_prev.contains("first message"), + "expected previous message after select_prev:\n{rendered_prev}" + ); - let event = app_event_rx.try_recv().expect("permissions update event"); - assert_eq!( - event, - AppEvent::Command(AppCommand::UpdatePermissions { - preset: devo_protocol::PermissionPreset::ReadOnly, - }) + transcript.select_next_user(); + let next = transcript + .selected_user_message() + .expect("selected next user"); + widget.restore_user_message_to_composer(next); + let rendered_next = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + rendered_next.contains("second message"), + "expected next message after select_next:\n{rendered_next}" ); } #[test] -fn permissions_command_marks_initial_project_preset_current() { +fn restoring_previous_message_truncates_later_transcript_history() { 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_thinking_selection: None, - initial_permission_preset: PermissionPreset::FullAccess, - initial_user_message: None, - enhanced_keys_supported: true, - is_first_run: false, - available_models: Vec::new(), - saved_model_slugs: Vec::new(), - show_model_onboarding: false, - startup_tooltip_override: None, - initial_theme_name: None, - }); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_app_event(AppEvent::RunSlashCommand { - command: "permissions".to_string(), - }); + widget.submit_text("first message".to_string()); + widget.add_to_history(crate::history_cell::PlainHistoryCell::new(vec![ + Line::from("assistant 1"), + ])); + widget.submit_text("second message".to_string()); + widget.add_to_history(crate::history_cell::PlainHistoryCell::new(vec![ + Line::from("assistant 2"), + ])); + let _ = widget.drain_scrollback_lines(80); - let rendered = rendered_rows(&widget, 100, 18).join("\n"); - assert!(rendered.contains("Full Access (current)")); + widget.truncate_history_to_user_turn_count(1); + widget.restore_user_message_to_composer(crate::chatwidget::UserMessage::from("first message")); + + let rendered = rendered_rows(&widget, 80, 16).join("\n"); + assert!(rendered.contains("first message")); + let transcript_lines = widget + .transcript_overlay_cells(80) + .into_iter() + .flat_map(|cell| cell.lines) + .flat_map(|line| line.spans.into_iter()) + .map(|span| span.content) + .collect::(); + assert!(transcript_lines.contains("first message")); + assert!(!transcript_lines.contains("second message")); + assert!(!transcript_lines.contains("assistant 2")); } #[test] -fn thinking_entries_are_generated_from_model_capability_options() { +fn esc_backtrack_hint_is_shown_before_restore() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), - thinking_capability: ThinkingCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ]), - default_reasoning_effort: Some(ReasoningEffort::Medium), ..Model::default() }; - let (widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - assert_eq!( - widget.thinking_entries(), - vec![ - ThinkingListEntry { - is_current: false, - label: "Low".to_string(), - description: "Fastest, cheapest, least deliberative".to_string(), - value: "low".to_string(), - }, - ThinkingListEntry { - is_current: true, - label: "Medium".to_string(), - description: "Balanced speed and deliberation".to_string(), - value: "medium".to_string(), - }, - ] + widget.show_esc_backtrack_hint(); + let rendered = rendered_rows(&widget, 100, 14).join("\n"); + assert!( + rendered.contains("esc again to edit previous message") + || rendered.contains("esc esc to edit previous message"), + "expected esc backtrack hint before opening overlay:\n{rendered}" ); } #[test] -fn initial_thinking_selection_overrides_model_default() { +fn resume_command_opens_loading_browser_immediately() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), - thinking_capability: ThinkingCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ]), - default_reasoning_effort: Some(ReasoningEffort::Medium), ..Model::default() }; - let (widget, _app_event_rx) = - widget_with_model_and_thinking(model, PathBuf::from("."), Some("low".to_string())); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - assert_eq!(widget.current_thinking_selection(), Some("low")); -} + widget.handle_app_event(AppEvent::Command(AppCommand::RunUserShellCommand { + command: "session list".to_string(), + })); -#[test] -fn slash_command_list_does_not_include_thinking() { - let commands = built_in_slash_commands(); - assert!(!commands.iter().any(|(name, _)| *name == "thinking")); + assert!(widget.is_resume_browser_open()); + + let rows = rendered_rows(&widget, 80, 12); + assert!( + rows.iter() + .any(|row| row.contains("Loading saved sessions")) + ); } #[test] -fn busy_widget_blocks_model_change_with_transcript_message() { +fn resume_loading_browser_closes_with_esc_or_q() { 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 (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + widget.handle_app_event(AppEvent::Command(AppCommand::RunUserShellCommand { + command: "session list".to_string(), + })); + assert!(widget.is_resume_browser_open()); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_paste("/model".to_string()); - widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!widget.is_resume_browser_open()); - assert!(app_event_rx.try_recv().is_err()); + widget.handle_app_event(AppEvent::Command(AppCommand::RunUserShellCommand { + command: "session list".to_string(), + })); + assert!(widget.is_resume_browser_open()); - let scrollback = widget - .drain_scrollback_lines(80) - .into_iter() - .map(|line| { - line.line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); - assert!(scrollback.contains("Cannot change model while generating")); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + assert!(!widget.is_resume_browser_open()); } #[test] -fn toggle_with_levels_treats_enabled_as_default_effort_in_picker() { +fn resume_browser_clips_sessions_to_viewport_height() { let model = Model { - slug: "deepseek-v4".to_string(), - display_name: "Deepseek V4".to_string(), - thinking_capability: ThinkingCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), ..Model::default() }; - let (widget, _app_event_rx) = - widget_with_model_and_thinking(model, PathBuf::from("."), Some("enabled".to_string())); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let sessions = (0..12) + .map(|index| crate::events::SessionListEntry { + session_id: SessionId::new(), + title: format!("Session {index}"), + updated_at: format!("2026-05-{index:02} 10:00"), + is_active: index == 0, + }) + .collect(); + widget.open_resume_browser_for_test(sessions); - assert_eq!( - widget.thinking_entries(), - vec![ - ThinkingListEntry { - is_current: false, - label: "Off".to_string(), - description: "Disable thinking for this turn".to_string(), - value: "disabled".to_string(), - }, - ThinkingListEntry { - is_current: true, - label: "High".to_string(), - description: "More deliberate for harder tasks".to_string(), - value: "high".to_string(), - }, - ThinkingListEntry { - is_current: false, - label: "Max".to_string(), - description: "Most deliberate, highest effort".to_string(), - value: "max".to_string(), - }, - ] + let rows = rendered_rows(&widget, 80, 10); + let blob = rows.join("\n"); + assert!(blob.contains("Session 0")); + assert!(blob.contains("Session 1")); + assert!( + !blob.contains("Session 2"), + "rows should be clipped to viewport:\n{blob}" + ); + assert!( + !blob.contains("Session 3"), + "rows should be clipped to viewport:\n{blob}" + ); + assert!( + blob.contains("↓ more"), + "expected lower overflow indicator:\n{blob}" ); } #[test] -fn thinking_entries_show_off_and_levels_for_toggle_models_with_supported_levels() { - let model = devo_core::ModelPreset { - slug: "deepseek-v4".to_string(), - display_name: "Deepseek V4".to_string(), - thinking_capability: ThinkingCapability::Toggle, - supported_reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::Max], - default_reasoning_effort: None, - ..devo_core::ModelPreset::default() - } - .into(); - let (widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); +fn resume_browser_list_closes_with_esc_or_q() { + 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 sessions = vec![crate::events::SessionListEntry { + session_id: SessionId::new(), + title: "Session".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: true, + }]; + widget.open_resume_browser_for_test(sessions.clone()); + assert!(widget.is_resume_browser_open()); - assert_eq!( - widget.thinking_entries(), - vec![ - ThinkingListEntry { - is_current: false, - label: "Off".to_string(), - description: "Disable thinking for this turn".to_string(), - value: "disabled".to_string(), - }, - ThinkingListEntry { - is_current: true, - label: "High".to_string(), - description: "More deliberate for harder tasks".to_string(), - value: "high".to_string(), - }, - ThinkingListEntry { - is_current: false, - label: "Max".to_string(), - description: "Most deliberate, highest effort".to_string(), - value: "max".to_string(), - }, - ] - ); + widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!widget.is_resume_browser_open()); + + widget.open_resume_browser_for_test(sessions); + assert!(widget.is_resume_browser_open()); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + assert!(!widget.is_resume_browser_open()); } #[test] -fn submit_text_emits_user_turn_with_model_and_thinking() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_keeps_selection_visible_when_navigating_down() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), - thinking_capability: ThinkingCapability::Toggle, ..Model::default() }; - let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let sessions = (0..12) + .map(|index| crate::events::SessionListEntry { + session_id: SessionId::new(), + title: format!("Session {index}"), + updated_at: format!("2026-05-{index:02} 10:00"), + is_active: index == 0, + }) + .collect(); + widget.open_resume_browser_for_test(sessions); - widget.set_thinking_selection(Some("disabled".to_string())); - widget.submit_text("hello".to_string()); + for _ in 0..11 { + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } - assert_eq!( - app_event_rx.try_recv().expect("command event is emitted"), - AppEvent::Command(AppCommand::UserTurn { - input: vec![InputItem::Text { - text: "hello".to_string(), - }], - cwd: Some(cwd), - model: Some("test-model".to_string()), - thinking: Some("disabled".to_string()), - sandbox: None, - approval_policy: Some("on-request".to_string()), - }) + assert_eq!(widget.resume_browser_selection_for_test(), Some(11)); + + let rows = rendered_rows(&widget, 80, 10); + let blob = rows.join("\n"); + assert!( + blob.contains("Session 11"), + "selected tail item should be visible:\n{blob}" + ); + assert!( + !blob.contains("Session 0"), + "viewport should have scrolled away from the head:\n{blob}" + ); + assert!( + blob.contains("↑ more"), + "expected upper overflow indicator after scrolling:\n{blob}" ); } #[test] -fn typed_character_submits_after_paste_burst_flush() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_enter_resumes_selected_scrolled_session() { 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 (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let sessions: Vec<_> = (0..12) + .map(|index| crate::events::SessionListEntry { + session_id: SessionId::new(), + title: format!("Session {index}"), + updated_at: format!("2026-05-{index:02} 10:00"), + is_active: index == 0, + }) + .collect(); + let expected = sessions[11].session_id; + widget.open_resume_browser_for_test(sessions); - widget.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); - std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); - widget.pre_draw_tick(); + for _ in 0..11 { + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let emitted_command = std::iter::from_fn(|| app_event_rx.try_recv().ok()) - .find(|event| matches!(event, AppEvent::Command(_))) - .expect("command event is emitted"); + let event = app_event_rx + .try_recv() + .expect("resume selection should emit switch command"); assert_eq!( - emitted_command, - AppEvent::Command(AppCommand::UserTurn { - input: vec![InputItem::Text { - text: "a".to_string(), - }], - cwd: Some(cwd), - model: Some("test-model".to_string()), - thinking: None, - sandbox: None, - approval_policy: Some("on-request".to_string()), - }) + event, + AppEvent::Command(AppCommand::switch_session(expected)) ); } #[test] -fn key_release_does_not_duplicate_text_input() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_supports_page_and_home_end_navigation() { 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 (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let sessions: Vec<_> = (0..12) + .map(|index| crate::events::SessionListEntry { + session_id: SessionId::new(), + title: format!("Session {index}"), + updated_at: format!("2026-05-{index:02} 10:00"), + is_active: index == 0, + }) + .collect(); + widget.open_resume_browser_for_test(sessions); + let _ = rendered_rows(&widget, 80, 10); - widget.handle_key_event(KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: crossterm::event::KeyEventState::NONE, - }); - widget.handle_key_event(KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Release, - state: crossterm::event::KeyEventState::NONE, - }); - std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); - widget.pre_draw_tick(); - widget.handle_key_event(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: crossterm::event::KeyEventState::NONE, - }); + widget.handle_key_event(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(3)); - let emitted_command = std::iter::from_fn(|| app_event_rx.try_recv().ok()) - .find(|event| matches!(event, AppEvent::Command(_))) - .expect("command event is emitted"); - assert_eq!( - emitted_command, - AppEvent::Command(AppCommand::UserTurn { - input: vec![InputItem::Text { - text: "a".to_string(), - }], - cwd: Some(cwd), - model: Some("test-model".to_string()), - thinking: None, - sandbox: None, - approval_policy: Some("on-request".to_string()), - }) + widget.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(11)); + + widget.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(0)); + + let blob = rendered_rows(&widget, 80, 10).join("\n"); + assert!( + blob.contains("pgup/pgdn page"), + "expected paging hint text in resume browser:\n{blob}" + ); + assert!( + blob.contains("home/end jump"), + "expected home/end hint text in resume browser:\n{blob}" ); } #[test] -fn startup_header_mascot_animation_advances_on_pre_draw_tick() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_up_down_do_not_wrap_around() { 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 widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let sessions: Vec<_> = (0..4) + .map(|index| crate::events::SessionListEntry { + session_id: SessionId::new(), + title: format!("Session {index}"), + updated_at: format!("2026-05-{index:02} 10:00"), + is_active: index == 0, + }) + .collect(); + widget.open_resume_browser_for_test(sessions); - assert_eq!(widget.startup_header_mascot_frame_index(), 0); + widget.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(0)); - widget.force_startup_header_animation_due(); - widget.pre_draw_tick(); + widget.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(3)); - assert_eq!(widget.startup_header_mascot_frame_index(), 1); + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(3)); } #[test] -fn onboarding_view_is_active_on_first_run() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_shows_position_and_scroll_progress() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let (_widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); - // Onboarding view is pushed onto the view stack on first run. - // The UI is now managed by the OnboardingView via the bottom pane view stack. + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let sessions: Vec<_> = (0..12) + .map(|index| crate::events::SessionListEntry { + session_id: SessionId::new(), + title: format!("Session {index}"), + updated_at: format!("2026-05-{index:02} 10:00"), + is_active: index == 0, + }) + .collect(); + widget.open_resume_browser_for_test(sessions); + let _ = rendered_rows(&widget, 80, 10); + widget.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + + let blob = rendered_rows(&widget, 80, 10).join("\n"); + assert!( + blob.contains("12 / 12"), + "expected position label in resume header:\n{blob}" + ); + assert!( + blob.contains("100%"), + "expected scroll percent in resume header:\n{blob}" + ); } #[test] -fn onboarding_validation_succeeded_clears_active_state() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_title_uses_ascii_ellipsis_when_too_long() { let model = Model { - slug: "anthropic-messages-model".to_string(), + slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + widget.open_resume_browser_for_test(vec![crate::events::SessionListEntry { + session_id: SessionId::new(), + title: "This is a very long session title that should be truncated in resume browser" + .to_string(), + updated_at: "2026-05-17 10:00".to_string(), + is_active: true, + }]); + + let blob = rendered_rows(&widget, 54, 10).join("\n"); + assert!( + blob.contains("..."), + "expected ASCII ellipsis truncation in title column:\n{blob}" + ); +} - // Simulate validation success from the worker. - widget.handle_worker_event(crate::events::WorkerEvent::ProviderValidationSucceeded { - reply_preview: "OK".to_string(), - }); +#[test] +fn resume_browser_dash_only_title_is_truncated_with_ascii_ellipsis() { + 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.open_resume_browser_for_test(vec![crate::events::SessionListEntry { + session_id: SessionId::new(), + title: "------------------------------------------------------------".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: true, + }]); + + let blob = rendered_rows(&widget, 54, 10).join("\n"); + assert!( + blob.contains("..."), + "expected dash-only title to be truncated with ASCII ellipsis:\n{blob}" + ); +} - // After validation, placeholder should be reset to default. - assert_eq!(widget.placeholder_text(), "Ask Devo"); +#[test] +fn resume_browser_cjk_title_truncates_by_display_width() { + 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.open_resume_browser_for_test(vec![crate::events::SessionListEntry { + session_id: SessionId::new(), + title: "这是一个非常非常长的中文会话标题用于测试截断显示是否正确".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: true, + }]); + + let blob = rendered_rows(&widget, 54, 10).join("\n"); + assert!( + blob.contains("..."), + "expected CJK title truncation to include ASCII ellipsis:\n{blob}" + ); + assert!( + !blob.contains("是否正确"), + "expected tail of long CJK title to be truncated:\n{blob}" + ); } #[test] -fn streamed_lines_stay_in_live_viewport_until_turn_finishes() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn resume_browser_cjk_and_ascii_titles_keep_session_id_column_aligned() { 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.clone(), cwd); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let cjk_session_id = SessionId::new(); + let ascii_session_id = SessionId::new(); + widget.open_resume_browser_for_test(vec![ + crate::events::SessionListEntry { + session_id: cjk_session_id, + title: "中文标题用于对齐测试".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: true, + }, + crate::events::SessionListEntry { + session_id: ascii_session_id, + title: "ASCII title".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: false, + }, + ]); + + let area = ratatui::layout::Rect::new(0, 0, 90, 10); + let mut buf = ratatui::buffer::Buffer::empty(area); + widget.render(area, &mut buf); - let base_height = widget.desired_height(80); - for index in 0..12 { - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta(format!( - "line {index}\n" - ))); + let cjk_id_text = cjk_session_id.to_string(); + let ascii_id_text = ascii_session_id.to_string(); + let mut cjk_pos = None; + let mut ascii_pos = None; + for row in 0..area.height { + let row_text = (0..area.width) + .map(|col| buf[(col, row)].symbol()) + .collect::(); + if row_text.contains(&cjk_id_text) { + cjk_pos = (0..area.width).find(|col| { + let tail = (*col..area.width) + .map(|scan_col| buf[(scan_col, row)].symbol()) + .collect::(); + tail.starts_with(&cjk_id_text) + }); + } + if row_text.contains(&ascii_id_text) { + ascii_pos = (0..area.width).find(|col| { + let tail = (*col..area.width) + .map(|scan_col| buf[(scan_col, row)].symbol()) + .collect::(); + tail.starts_with(&ascii_id_text) + }); + } } - - assert!(widget.desired_height(80) > base_height); - - let committed_before_finish = widget.drain_scrollback_lines(80); - let committed_before_finish_text = committed_before_finish - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - assert!(!committed_before_finish_text.contains("line 0")); - assert!(!committed_before_finish_text.contains("line 11")); - - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "stop".to_string(), - turn_count: 1, - 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, - }); - - let committed_after_finish = widget.drain_scrollback_lines(80); - let committed_after_finish_text = committed_after_finish - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - assert!(committed_after_finish_text.contains("line 0")); - assert!(committed_after_finish_text.contains("line 11")); + let cjk_col = cjk_pos.expect("cjk session id column"); + let ascii_col = ascii_pos.expect("ascii session id column"); + assert_eq!( + cjk_col, ascii_col, + "expected Session ID column alignment across CJK and ASCII rows" + ); } #[test] -fn committed_history_drains_to_scrollback_lines() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn approval_request_renders_bottom_pane_menu_and_accepts_once() { 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.clone(), cwd.clone()); - - let initial_lines = widget.drain_scrollback_lines(80); - assert!(!initial_lines.is_empty()); + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "done".to_string(), - turn_count: 1, - total_input_tokens: 10, - total_output_tokens: 20, - total_cache_read_tokens: 0, - last_query_total_tokens: 30, - last_query_input_tokens: 10, - prompt_token_estimate: 10, + widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id: "approval-call-1".to_string(), + action_summary: "write src/main.rs".to_string(), + justification: "Tool execution requires approval.".to_string(), + resource: Some("FileWrite".to_string()), + available_scopes: vec!["once".to_string(), "session".to_string()], + path: Some("src/main.rs".to_string()), + host: None, + target: None, }); - let committed_lines = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); - // TurnSummaryCell is now added on TurnFinished, so scrollback is non-empty. - assert!( - !committed_lines.is_empty(), - "TurnSummaryCell should be committed" - ); - assert!( - committed_lines.iter().any(|line| { - line.line - .spans - .iter() - .any(|span| span.content.contains("▣")) - }), - "expected ▣ symbol in turn summary" + let scrollback = widget.drain_scrollback_lines(80); + assert!(!scrollback_contains_text( + &scrollback, + "Permission required" + )); + + let rendered = rendered_rows(&widget, 80, 16).join("\n"); + assert!(rendered.contains("Permission approval required")); + assert!(rendered.contains("Approve once")); + assert!(rendered.contains("Approve for session")); + assert!(rendered.contains("Deny")); + + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let event = app_event_rx.try_recv().expect("approval response event"); + assert_eq!( + event, + AppEvent::Command(AppCommand::ApprovalRespond { + session_id, + turn_id, + approval_id: "approval-call-1".to_string(), + decision: ApprovalDecisionValue::Approve, + scope: ApprovalScopeValue::Once, + }) ); } #[test] -fn streamed_history_stays_empty_until_turn_finishes() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn approval_request_does_not_duplicate_already_committed_assistant_text() { 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.clone(), cwd.clone()); + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let item_id = ItemId::new(); + let text = "明白,我来随便加点内容,测试一下 apply_patch。".to_string(); - let _ = widget.drain_scrollback_lines(80); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "first\nsecond\n".to_string(), + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id, + kind: crate::events::TextItemKind::Assistant, + delta: text.clone(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id, + kind: crate::events::TextItemKind::Assistant, + final_text: text.clone(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::AssistantMessageCompleted( + text.clone(), )); - let committed_lines = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); - assert!(committed_lines.is_empty()); + widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id: "approval-call-1".to_string(), + action_summary: "apply_patch".to_string(), + justification: "Tool execution requires approval.".to_string(), + resource: Some("FileWrite".to_string()), + available_scopes: vec!["once".to_string(), "session".to_string()], + path: Some("src/main.rs".to_string()), + host: None, + target: None, + }); + + let transcript = widget.transcript_overlay_lines(100); + let rows = transcript + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + assert_eq!( + rows.matches(&text).count(), + 1, + "assistant text should not be committed twice around approval request:\n{rows}" + ); } #[test] -fn batched_history_inserts_separator_and_trailing_blank_lines() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn approval_request_bottom_pane_menu_denies_with_n_shortcut() { 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.clone(), cwd.clone()); + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); - let _ = widget.drain_scrollback_lines(80); - widget.add_to_history(crate::history_cell::new_info_event( - "first".to_string(), - None, - )); - widget.add_to_history(crate::history_cell::new_info_event( - "second".to_string(), - None, - )); + widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id: "approval-call-2".to_string(), + action_summary: "run shell command".to_string(), + justification: "Tool execution requires approval.".to_string(), + resource: Some("ShellExec".to_string()), + available_scopes: vec!["once".to_string()], + path: None, + host: None, + target: Some("cargo test".to_string()), + }); - let committed_lines = widget.drain_scrollback_lines(80); - let blank_lines = committed_lines - .iter() - .filter(|line| { - line.line - .spans - .iter() - .all(|span| span.content.trim().is_empty()) - }) - .count(); + let rendered = rendered_rows(&widget, 80, 16).join("\n"); + assert!(rendered.contains("Permission approval required")); + assert!(rendered.contains("run shell command")); + assert!(rendered.contains("Deny")); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let event = app_event_rx.try_recv().expect("approval response event"); assert_eq!( - 2, blank_lines, - "unexpected blank lines: {committed_lines:?}" + event, + AppEvent::Command(AppCommand::ApprovalRespond { + session_id, + turn_id, + approval_id: "approval-call-2".to_string(), + decision: ApprovalDecisionValue::Deny, + scope: ApprovalScopeValue::Once, + }) ); } #[test] -fn session_switch_restores_header_and_double_blank_line_before_user_input() { - let initial_cwd = std::env::current_dir().expect("current directory is available"); - let resumed_cwd = initial_cwd.join("resumed"); +fn submitted_prompt_requests_on_request_approval_policy() { let model = Model { - slug: "initial-model".to_string(), - display_name: "Initial Model".to_string(), + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd); + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); - let _ = widget.drain_scrollback_lines(80); - widget.add_to_history(crate::history_cell::new_info_event( - "session 1 lingering line".to_string(), - None, - )); - let _ = widget.drain_scrollback_lines(80); - widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { - session_id: "session-1".to_string(), - cwd: resumed_cwd.clone(), - title: Some("Resumed".to_string()), - model: Some("resumed-model".to_string()), + widget.submit_text("please edit a file".to_string()); + + let event = app_event_rx.try_recv().expect("user turn event"); + let AppEvent::Command(AppCommand::UserTurn { + approval_policy, .. + }) = event + else { + panic!("expected user turn command"); + }; + assert_eq!(approval_policy, Some("on-request".to_string())); +} + +#[test] +fn permissions_command_opens_bottom_pane_picker_and_updates_default() { + 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_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), thinking: None, reasoning_effort: None, - total_input_tokens: 3, - total_output_tokens: 5, - total_cache_read_tokens: 0, - last_query_total_tokens: 8, - last_query_input_tokens: 3, - prompt_token_estimate: 3, - history_items: vec![ - crate::events::TranscriptItem::new( - crate::events::TranscriptItemKind::User, - String::new(), - "hello".to_string(), - ), - crate::events::TranscriptItem::new( - crate::events::TranscriptItemKind::Assistant, - String::new(), - "world".to_string(), - ), - ], - loaded_item_count: 2, - pending_texts: vec![], + turn_id: TurnId::new(), }); - let committed_lines = widget.drain_scrollback_lines(80); - let committed_text = committed_lines - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - let committed_rows = committed_lines - .iter() - .map(|line| { - line.line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); + widget.handle_app_event(AppEvent::RunSlashCommand { + command: "permissions".to_string(), + }); - // The header box is rendered only once on initial launch, not on session switch. - assert_eq!(0, committed_text.matches("directory:").count()); - assert!(committed_text.contains("hello")); - assert!(committed_text.contains("world")); - assert!(!committed_text.contains("session 1 lingering line")); - assert!( - committed_rows - .windows(3) - .any(|window| window[0].trim_end() == "▌" - && window[1].contains("hello") - && window[2].trim_end() == "▌"), - "expected blank line spacing with colored bar before restored user input: {committed_lines:?}" + let rendered = rendered_rows(&widget, 100, 18).join("\n"); + assert!(rendered.contains("Update Model Permissions")); + assert!(rendered.contains("Read Only")); + assert!(rendered.contains("Default (current)")); + assert!(rendered.contains("Auto-review")); + assert!(rendered.contains("Full Access")); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + + let event = app_event_rx.try_recv().expect("permissions update event"); + assert_eq!( + event, + AppEvent::Command(AppCommand::UpdatePermissions { + preset: devo_protocol::PermissionPreset::ReadOnly, + }) ); } #[test] -fn turn_finished_does_not_add_completion_status_line_to_history() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn permissions_command_marks_initial_project_preset_current() { 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.clone(), cwd.clone()); + 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_thinking_selection: None, + initial_permission_preset: PermissionPreset::FullAccess, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_model_slugs: Vec::new(), + show_model_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + }); - let _ = widget.drain_scrollback_lines(80); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "Completed".to_string(), - turn_count: 1, - 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, + widget.handle_app_event(AppEvent::RunSlashCommand { + command: "permissions".to_string(), }); - let committed_lines = widget.drain_scrollback_lines(80); - assert!(!committed_lines.iter().any(|line| { - line.line - .spans - .iter() - .any(|span| span.content.contains("Turn completed (Completed)")) - })); + let rendered = rendered_rows(&widget, 100, 18).join("\n"); + assert!(rendered.contains("Full Access (current)")); } #[test] -fn completed_turn_summary_keeps_duration_for_text_turns() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn thinking_entries_are_generated_from_model_capability_options() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), + thinking_capability: ThinkingCapability::Levels(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ]), + default_reasoning_effort: Some(ReasoningEffort::Medium), ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - - let _ = widget.drain_scrollback_lines(80); - widget.force_task_elapsed_seconds(3); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta("hello".to_string())); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "Completed".to_string(), - turn_count: 1, - 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, - }); - - let committed = widget - .drain_scrollback_lines(80) - .into_iter() - .map(|line| { - line.line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); + let (widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - assert!(committed.contains("▣")); - assert!(committed.contains("Test Model")); - assert!(committed.contains("3s")); + assert_eq!( + widget.thinking_entries(), + vec![ + ThinkingListEntry { + is_current: false, + label: "Low".to_string(), + description: "Fastest, cheapest, least deliberative".to_string(), + value: "low".to_string(), + }, + ThinkingListEntry { + is_current: true, + label: "Medium".to_string(), + description: "Balanced speed and deliberation".to_string(), + value: "medium".to_string(), + }, + ] + ); } #[test] -fn active_response_renders_generating_status_without_devo_title() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn initial_thinking_selection_overrides_model_default() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), + thinking_capability: ThinkingCapability::Levels(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ]), + default_reasoning_effort: Some(ReasoningEffort::Medium), ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - - let _ = widget.drain_scrollback_lines(80); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta("hello".to_string())); + let (widget, _app_event_rx) = + widget_with_model_and_thinking(model, PathBuf::from("."), Some("low".to_string())); - let rendered = rendered_rows(&widget, 80, 12).join("\n"); - assert!(!rendered.contains("Devo -")); + assert_eq!(widget.current_thinking_selection(), Some("low")); } #[test] -fn streaming_pending_ai_reply_respects_wrap_limit_before_finalize() { - 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_app_event(AppEvent::ClearTranscript); - let _ = widget.drain_scrollback_lines(80); - - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "see https://example.test/path/abcdef12345 tail words".to_string(), - )); - - let rendered = rendered_rows(&widget, 24, 12).join("\n"); - assert!( - rendered.contains("tail words"), - "expected pending streaming reply to wrap suffix words together, got:\n{rendered}" - ); +fn slash_command_list_does_not_include_thinking() { + let commands = built_in_slash_commands(); + assert!(!commands.iter().any(|(name, _)| *name == "thinking")); } #[test] -fn active_assistant_markdown_does_not_double_wrap() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn busy_widget_blocks_model_change_with_transcript_message() { 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 body = format!("{} betabet gamma", ["alpha"; 12].join(" ")); + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { model: "test-model".to_string(), @@ -1053,241 +1096,276 @@ fn active_assistant_markdown_does_not_double_wrap() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta(body)); + widget.handle_paste("/model".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let rendered = rendered_rows(&widget, 80, 12).join("\n"); - assert!( - rendered.contains("betabet gamma"), - "expected active assistant markdown to keep trailing words together, got:\n{rendered}" - ); + assert!(app_event_rx.try_recv().is_err()); + + let scrollback = widget + .drain_scrollback_lines(80) + .into_iter() + .map(|line| { + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(scrollback.contains("Cannot change model while generating")); } #[test] -fn active_assistant_multiline_text_has_no_extra_blank_rows() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn toggle_with_levels_treats_enabled_as_default_effort_in_picker() { let model = Model { - slug: "test-model".to_string(), - display_name: "Test Model".to_string(), + slug: "deepseek-v4".to_string(), + display_name: "Deepseek V4".to_string(), + thinking_capability: ThinkingCapability::ToggleWithLevels(vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + let (widget, _app_event_rx) = + widget_with_model_and_thinking(model, PathBuf::from("."), Some("enabled".to_string())); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "Line1\nLine2\nLine3\n".to_string(), - )); + assert_eq!( + widget.thinking_entries(), + vec![ + ThinkingListEntry { + is_current: false, + label: "Off".to_string(), + description: "Disable thinking for this turn".to_string(), + value: "disabled".to_string(), + }, + ThinkingListEntry { + is_current: true, + label: "High".to_string(), + description: "More deliberate for harder tasks".to_string(), + value: "high".to_string(), + }, + ThinkingListEntry { + is_current: false, + label: "Max".to_string(), + description: "Most deliberate, highest effort".to_string(), + value: "max".to_string(), + }, + ] + ); +} - let rows = rendered_rows(&widget, 80, 12); - let line1 = find_row_index(&rows, "Line1").expect("missing Line1"); - let line2 = find_row_index(&rows, "Line2").expect("missing Line2"); - let line3 = find_row_index(&rows, "Line3").expect("missing Line3"); - assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); - assert_eq!(line3, line2 + 1, "unexpected rows:\n{}", rows.join("\n")); +#[test] +fn thinking_entries_show_off_and_levels_for_toggle_models_with_supported_levels() { + let model = devo_core::ModelPreset { + slug: "deepseek-v4".to_string(), + display_name: "Deepseek V4".to_string(), + thinking_capability: ThinkingCapability::Toggle, + supported_reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::Max], + default_reasoning_effort: None, + ..devo_core::ModelPreset::default() + } + .into(); + let (widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + assert_eq!( + widget.thinking_entries(), + vec![ + ThinkingListEntry { + is_current: false, + label: "Off".to_string(), + description: "Disable thinking for this turn".to_string(), + value: "disabled".to_string(), + }, + ThinkingListEntry { + is_current: true, + label: "High".to_string(), + description: "More deliberate for harder tasks".to_string(), + value: "high".to_string(), + }, + ThinkingListEntry { + is_current: false, + label: "Max".to_string(), + description: "Most deliberate, highest effort".to_string(), + value: "max".to_string(), + }, + ] + ); } #[test] -fn active_assistant_renders_resume_like_markdown_without_fragment_gaps() { +fn submit_text_emits_user_turn_with_model_and_thinking() { 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(), + thinking_capability: ThinkingCapability::Toggle, ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "## devo-cli -- Binary entry point that assembles all crates\n\n".to_string(), - )); - widget.pre_draw_tick(); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "4 source files, produces the devo binary.\n\n".to_string(), - )); - widget.pre_draw_tick(); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "Command dispatch (/crates/cli/src/main.rs)\n\n".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "devo -> run_agent() interactive TUI (default)\n".to_string(), - )); + let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); - let rows = rendered_rows(&widget, 180, 24); - let indices = indices_containing( - &rows, - &[ - "devo-cli", - "4 source files", - "Command dispatch", - "run_agent", - ], - ); + widget.set_thinking_selection(Some("disabled".to_string())); + widget.submit_text("hello".to_string()); assert_eq!( - indices - .windows(2) - .map(|pair| pair[1] - pair[0]) - .collect::>(), - vec![2, 2, 2], - "expected active assistant markdown blocks to have one separator row, not doubled gaps:\n{}", - rows.join("\n") + app_event_rx.try_recv().expect("command event is emitted"), + AppEvent::Command(AppCommand::UserTurn { + input: vec![InputItem::Text { + text: "hello".to_string(), + }], + cwd: Some(cwd), + model: Some("test-model".to_string()), + thinking: Some("disabled".to_string()), + sandbox: None, + approval_policy: Some("on-request".to_string()), + }) ); } #[test] -fn committed_assistant_markdown_does_not_double_wrap() { +fn typed_character_submits_after_paste_burst_flush() { 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 body = format!("{} betabet gamma", ["alpha"; 12].join(" ")); + let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta(body)); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "Completed".to_string(), - turn_count: 1, - 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, - }); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('a'), 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)); - let committed = widget - .drain_scrollback_lines(80) - .into_iter() - .map(|line| { - line.line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() + let emitted_command = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::Command(_))) + .expect("command event is emitted"); + assert_eq!( + emitted_command, + AppEvent::Command(AppCommand::UserTurn { + input: vec![InputItem::Text { + text: "a".to_string(), + }], + cwd: Some(cwd), + model: Some("test-model".to_string()), + thinking: None, + sandbox: None, + approval_policy: Some("on-request".to_string()), }) - .collect::>() - .join("\n"); - assert!( - committed.contains("betabet gamma"), - "expected committed assistant markdown to keep trailing words together, got:\n{committed}" ); } +fn assert_no_command_emitted(app_event_rx: &mut mpsc::UnboundedReceiver) { + let command = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::Command(_))); + assert_eq!(command, None); +} + +fn submitted_text_after_modified_enter( + modifier: KeyModifiers, + test_model: Model, + cwd: PathBuf, +) -> String { + let (mut widget, mut app_event_rx) = widget_with_model(test_model, cwd); + + widget.handle_paste("hello".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, modifier)); + assert_no_command_emitted(&mut app_event_rx); + widget.handle_paste("world".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let emitted_command = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::Command(_))) + .expect("command event is emitted"); + let AppEvent::Command(AppCommand::UserTurn { input, .. }) = emitted_command else { + unreachable!("filtered for user command"); + }; + let [InputItem::Text { text }] = input.as_slice() else { + panic!("expected one text input item, got {input:?}"); + }; + text.clone() +} + #[test] -fn committed_assistant_multiline_text_has_no_extra_blank_rows() { +fn shift_enter_inserts_newline_in_composer_without_submitting() { 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::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "Line1\nLine2\nLine3\n".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "Completed".to_string(), - turn_count: 1, - 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, - }); + let text = submitted_text_after_modified_enter(KeyModifiers::SHIFT, model, cwd); - let lines = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( - widget.drain_scrollback_lines(80), - )); - let line1 = lines - .iter() - .position(|line| line.contains("Line1")) - .unwrap(); - let line2 = lines - .iter() - .position(|line| line.contains("Line2")) - .unwrap(); - let line3 = lines - .iter() - .position(|line| line.contains("Line3")) - .unwrap(); - assert_eq!(line2, line1 + 1, "unexpected lines:\n{}", lines.join("\n")); - assert_eq!(line3, line2 + 1, "unexpected lines:\n{}", lines.join("\n")); + assert_eq!(text, "hello\nworld"); } #[test] -fn tool_call_start_and_finish_are_both_visible_in_history() { +fn ctrl_enter_inserts_newline_in_composer_without_submitting() { 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 _ = widget.drain_scrollback_lines(80); - - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "powershell -NoProfile -Command Get-Date".to_string(), - }); - let running = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); - assert!( - running.contains("Running powershell -NoProfile -Command Get-Date"), - "expected running tool cell, got:\n{running}" - ); + let text = submitted_text_after_modified_enter(KeyModifiers::CONTROL, model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "powershell -NoProfile -Command Get-Date".to_string(), - preview: "2026-05-09".to_string(), - is_error: false, - truncated: false, - }); + assert_eq!(text, "hello\nworld"); +} - let ran = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); - assert!( - ran.contains("Ran powershell -NoProfile -Command Get-Date"), - "expected ran tool cell, got:\n{ran}" - ); - assert!( - ran.contains("2026-05-09"), - "expected tool output, got:\n{ran}" +#[test] +fn key_release_does_not_duplicate_text_input() { + 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()); + + widget.handle_key_event(KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }); + widget.handle_key_event(KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: crossterm::event::KeyEventState::NONE, + }); + std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); + widget.pre_draw_tick(); + widget.handle_key_event(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }); + + let emitted_command = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::Command(_))) + .expect("command event is emitted"); + assert_eq!( + emitted_command, + AppEvent::Command(AppCommand::UserTurn { + input: vec![InputItem::Text { + text: "a".to_string(), + }], + cwd: Some(cwd), + model: Some("test-model".to_string()), + thinking: None, + sandbox: None, + approval_policy: Some("on-request".to_string()), + }) ); } #[test] -fn reasoning_text_commits_to_history_when_turn_finishes() { +fn plan_update_updates_progress_and_history() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1296,39 +1374,45 @@ fn reasoning_text_commits_to_history_when_turn_finishes() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), + widget.handle_worker_event(crate::events::WorkerEvent::PlanUpdated { + explanation: Some("Working through checklist".to_string()), + steps: vec![ + PlanStep { + text: "Inspect implementation".to_string(), + status: PlanStepStatus::Completed, + }, + PlanStep { + text: "Patch runtime".to_string(), + status: PlanStepStatus::InProgress, + }, + ], }); - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( - "thinking text\n".to_string(), - )); - - let empty_scrollback = widget.drain_scrollback_lines(80); - assert!(!scrollback_contains_text( - &empty_scrollback, - "thinking text" - )); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "stop".to_string(), - turn_count: 1, - 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, - }); + assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); - let scrollback = widget.drain_scrollback_lines(80); - assert!(scrollback_contains_text(&scrollback, "thinking text")); + let lines = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); + assert!(lines.iter().any(|line| line.contains("Updated Plan"))); + assert!( + lines + .iter() + .any(|line| line.contains("Working through checklist")) + ); + assert!( + lines + .iter() + .any(|line| line.contains("Inspect implementation")) + ); + assert!(lines.iter().any(|line| line.contains("Patch runtime"))); + assert!( + lines + .iter() + .any(|line| line.contains(" ✔ Inspect implementation")) + ); + assert!(lines.iter().any(|line| line.contains(" → Patch runtime"))); } #[test] -fn restored_reasoning_text_is_visible_in_transcript() { +fn session_switch_restores_plan_metadata_into_progress() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1350,21 +1434,36 @@ fn restored_reasoning_text_is_visible_in_transcript() { last_query_total_tokens: 0, last_query_input_tokens: 0, prompt_token_estimate: 0, - history_items: vec![crate::events::TranscriptItem::new( - crate::events::TranscriptItemKind::Reasoning, - "", - "thinking text", - )], + history_items: Vec::new(), + rich_history_items: vec![devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::Assistant, + title: String::new(), + body: r#"{"explanation":"Do work","plan":[{"step":"Inspect","status":"completed"},{"step":"Patch","status":"in_progress"}]}"#.to_string(), + metadata: Some(devo_protocol::SessionHistoryMetadata::PlanUpdate { + explanation: Some("Do work".to_string()), + steps: vec![ + devo_protocol::SessionPlanStep { + text: "Inspect".to_string(), + status: devo_protocol::SessionPlanStepStatus::Completed, + }, + devo_protocol::SessionPlanStep { + text: "Patch".to_string(), + status: devo_protocol::SessionPlanStepStatus::InProgress, + }, + ], + }), + duration_ms: None, + }], loaded_item_count: 1, pending_texts: vec![], }); - let scrollback = widget.drain_scrollback_lines(80); - assert!(scrollback_contains_text(&scrollback, "thinking text")); + assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); } #[test] -fn reasoning_and_assistant_stream_in_separate_cells() { +fn session_switch_restores_explored_metadata_into_history() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1373,84 +1472,51 @@ fn reasoning_and_assistant_stream_in_separate_cells() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".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, - turn_id: Default::default(), + 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::CommandExecution, + title: "cat foo.txt".to_string(), + body: "hello".to_string(), + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "cat foo.txt".to_string(), + name: "foo.txt".to_string(), + path: PathBuf::from("foo.txt"), + }], + }), + duration_ms: None, + }], + loaded_item_count: 1, + pending_texts: vec![], }); - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( - "thinking".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "final answer line 1\nfinal answer line 2\n".to_string(), - )); - - let before_rows = rendered_rows(&widget, 80, 16); - let before = before_rows.join("\n"); - assert!( - before.contains("thinking") && before.contains("final answer line 1"), - "reasoning/text should both be visible while streaming:\n{before}" - ); - let reasoning_row = find_row_index(&before_rows, "thinking").expect("missing reasoning row"); - let assistant_row = - find_row_index(&before_rows, "final answer line 1").expect("missing assistant row"); - assert_eq!( - assistant_row, - reasoning_row + 2, - "expected one blank row between live cells" - ); - assert!( - before_rows[reasoning_row + 1].trim().is_empty(), - "expected blank separator row, got: {:?}", - before_rows[reasoning_row + 1] - ); - - widget.pre_draw_tick(); - let committed_before_reasoning_complete = - trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); - assert!( - !scrollback_contains_text(&committed_before_reasoning_complete, "final answer line 1"), - "assistant output should stay live, not drain to scrollback while reasoning is pending" - ); - let active_before_reasoning_complete = rendered_rows(&widget, 80, 16).join("\n"); - assert!( - active_before_reasoning_complete.contains("final answer line 1"), - "assistant output should remain visible in the active viewport:\n{active_before_reasoning_complete}" - ); - - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( - "thinking".to_string(), - )); - - // Reasoning is now committed to scrollback on ReasoningCompleted, - // no longer visible in the live viewport. - let after = rendered_rows(&widget, 80, 16).join("\n"); - assert!( - !after.contains("thinking"), - "reasoning text should commit to scrollback, not remain in viewport:\n{after}" - ); - let committed_after_reasoning_complete = - trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); - let committed_after_text = committed_after_reasoning_complete - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - committed_after_text.contains("thinking"), - "reasoning text should be in scrollback after ReasoningCompleted: {committed_after_reasoning_complete:?}" + blob.contains("Explored") || blob.contains("Exploring"), + "expected explored block after resume, got:\n{blob}" ); - let after_reasoning_rows = rendered_rows(&widget, 80, 16).join("\n"); assert!( - after_reasoning_rows.contains("final answer line 2"), - "undrained assistant output should remain active after reasoning completes:\n{after_reasoning_rows}" + blob.contains("Read foo.txt"), + "expected read summary, got:\n{blob}" ); } #[test] -fn lifecycle_text_items_render_as_ordered_sibling_cells() { +fn session_switch_restores_edited_metadata_into_history() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1458,68 +1524,51 @@ fn lifecycle_text_items_render_as_ordered_sibling_cells() { ..Model::default() }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - let reasoning_id = ItemId::new(); - let assistant_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("foo.txt"), + devo_protocol::protocol::FileChange::Update { + unified_diff: "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(), + move_path: None, + }, + ); + + 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, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "thinking".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "Line1\nLine2\n".to_string(), + 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: "apply_patch".to_string(), + body: String::new(), + metadata: Some(devo_protocol::SessionHistoryMetadata::Edited { changes }), + duration_ms: None, + }], + loaded_item_count: 1, + pending_texts: vec![], }); - let rows = rendered_rows(&widget, 80, 16); - let reasoning_row = find_row_index(&rows, "thinking").expect("missing reasoning row"); - let line1 = find_row_index(&rows, "Line1").expect("missing assistant row"); - let line2 = find_row_index(&rows, "Line2").expect("missing second assistant row"); - assert_eq!( - line1, - reasoning_row + 2, - "unexpected rows:\n{}", - rows.join("\n") - ); - assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); - - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - final_text: "thinking".to_string(), - }); - let rows_after_reasoning = rendered_rows(&widget, 80, 16); + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - !rows_after_reasoning - .iter() - .any(|row| row.contains("thinking")), - "completed reasoning should leave active viewport:\n{}", - rows_after_reasoning.join("\n") - ); - assert!( - rows_after_reasoning.iter().any(|row| row.contains("Line1")), - "assistant should remain active:\n{}", - rows_after_reasoning.join("\n") + blob.contains("Edited foo.txt") || blob.contains("Edited 1 file"), + "expected edited block after resume, got:\n{blob}" ); } #[test] -fn lifecycle_text_items_keep_reasoning_before_assistant_when_events_arrive_out_of_order() { +fn session_switch_merges_consecutive_explored_items() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1527,77 +1576,73 @@ fn lifecycle_text_items_keep_reasoning_before_assistant_when_events_arrive_out_o ..Model::default() }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - let reasoning_id = ItemId::new(); - let assistant_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, + 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, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "answer line\n".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "thinking text".to_string(), + 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![], + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolCall, + title: "read crates/tui/src/worker.rs".to_string(), + body: String::new(), + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read crates/tui/src/worker.rs".to_string(), + name: "worker.rs".to_string(), + path: PathBuf::from("crates/tui/src/worker.rs"), + }], + }), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-2".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolCall, + title: "grep command_actions in crates/tui/src/worker.rs".to_string(), + body: String::new(), + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep command_actions in crates/tui/src/worker.rs".to_string(), + query: Some("command_actions".to_string()), + path: Some("crates/tui/src/worker.rs".to_string()), + }], + }), + duration_ms: None, + }, + ], + loaded_item_count: 2, + pending_texts: vec![], }); - let rows = rendered_rows(&widget, 80, 16); - let reasoning_row = find_row_index(&rows, "thinking text").expect("missing reasoning row"); - let assistant_row = find_row_index(&rows, "answer line").expect("missing assistant row"); - assert!( - reasoning_row < assistant_row, - "reasoning should render above assistant:\n{}", - rows.join("\n") + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert_eq!( + blob.matches("Explored").count() + blob.matches("Exploring").count(), + 1, + "expected one merged explored block, got:\n{blob}" ); - - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - final_text: "answer line".to_string(), - }); - let committed_before_reasoning = widget.drain_scrollback_lines(80); assert!( - !scrollback_contains_text(&committed_before_reasoning, "answer line"), - "assistant should wait for prior reasoning before committing: {committed_before_reasoning:?}" + blob.contains("Read worker.rs"), + "expected read entry, got:\n{blob}" ); - - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - final_text: "thinking text".to_string(), - }); - let committed = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( - widget.drain_scrollback_lines(80), - )) - .join("\n"); - let reasoning_index = committed - .find("thinking text") - .expect("missing committed reasoning"); - let assistant_index = committed - .find("answer line") - .expect("missing committed assistant"); assert!( - reasoning_index < assistant_index, - "reasoning should commit before assistant:\n{committed}" + blob.contains("Search command_actions in crates/tui/src/worker.rs"), + "expected search entry, got:\n{blob}" ); } #[test] -fn assistant_stream_commit_tick_runs_while_reasoning_is_pending() { +fn session_switch_restores_error_via_tool_result_cell_style() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1605,157 +1650,106 @@ fn assistant_stream_commit_tick_runs_while_reasoning_is_pending() { ..Model::default() }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - let reasoning_id = ItemId::new(); - let assistant_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".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, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "thinking text".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "first line\nsecond line\n".to_string(), + 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![], + rich_history_items: vec![devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::Error, + title: "bash error".to_string(), + body: "permission denied".to_string(), + metadata: None, + duration_ms: None, + }], + loaded_item_count: 1, + pending_texts: vec![], }); - widget.pre_draw_tick(); - let committed = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); - let active = rendered_rows(&widget, 80, 16).join("\n"); + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - !committed.contains("first line"), - "assistant stream should stay out of scrollback until completion:\n{committed}" + blob.contains("Ran bash error"), + "expected tool-result style title, got:\n{blob}" ); assert!( - active.contains("first line"), - "assistant stream should remain visible even with pending reasoning:\n{active}" + blob.contains("permission denied"), + "expected tool-result body, got:\n{blob}" ); } -// TODO: Still buggy here, need to be fixed. -// #[test] -// fn slash_popup_shows_active_filter_hint() { -// 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_paste("/m".to_string()); - -// let rendered = rendered_rows(&widget, 80, 6).join("\n"); -// assert!(rendered.contains("filter: /m")); -// assert!(rendered.contains("/model")); -// } - #[test] -fn slash_model_opens_model_picker_instead_of_printing_current_model() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn live_and_resume_error_share_same_rendering_chain() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let alt_model = Model { - slug: "second-model".to_string(), - display_name: "Second Model".to_string(), - thinking_capability: ThinkingCapability::Levels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), - ..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, Some(model.clone())), - initial_thinking_selection: None, - initial_permission_preset: devo_protocol::PermissionPreset::Default, - initial_user_message: None, - enhanced_keys_supported: true, - is_first_run: false, - available_models: vec![model, alt_model], - saved_model_slugs: vec!["test-model".into(), "second-model".into()], - show_model_onboarding: false, - startup_tooltip_override: None, - initial_theme_name: None, - }); + let (mut live_widget, _live_rx) = widget_with_model(model.clone(), PathBuf::from(".")); + let (mut resume_widget, _resume_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_app_event(AppEvent::RunSlashCommand { - command: "model".to_string(), + live_widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "bash error".to_string(), + preview: "permission denied".to_string(), + is_error: true, + truncated: false, }); + let live_blob = scrollback_plain_lines(&live_widget.drain_scrollback_lines(80)) + .into_iter() + .filter(|line| line.contains("Ran bash error") || line.contains("permission denied")) + .collect::>() + .join("\n"); - assert_eq!(widget.placeholder_text(), "Ask Devo"); - assert_eq!( - widget.current_model().map(|m| m.slug.as_str()), - Some("test-model") - ); -} - -#[test] -fn session_switch_updates_session_identity_projection() { - let initial_cwd = std::env::current_dir().expect("current directory is available"); - let resumed_cwd = initial_cwd.join("resumed"); - let model = Model { - slug: "initial-model".to_string(), - display_name: "Initial Model".to_string(), - ..Model::default() - }; - let resumed_model = Model { - slug: "resumed-model".to_string(), - display_name: "Resumed Model".to_string(), - ..Model::default() - }; - let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd); - - widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + resume_widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { session_id: "session-1".to_string(), - cwd: resumed_cwd.clone(), - title: Some("Resumed".to_string()), - model: Some("resumed-model".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: 3, - total_output_tokens: 5, + total_input_tokens: 0, + total_output_tokens: 0, total_cache_read_tokens: 0, - last_query_total_tokens: 8, - last_query_input_tokens: 3, - prompt_token_estimate: 3, - history_items: Vec::new(), - loaded_item_count: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: vec![], + rich_history_items: vec![devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::Error, + title: "bash error".to_string(), + body: "permission denied".to_string(), + metadata: None, + duration_ms: None, + }], + loaded_item_count: 1, pending_texts: vec![], }); + let resume_blob = scrollback_plain_lines(&resume_widget.drain_scrollback_lines(80)) + .into_iter() + .filter(|line| line.contains("Ran bash error") || line.contains("permission denied")) + .collect::>() + .join("\n"); - assert_eq!(widget.current_cwd(), resumed_cwd.as_path()); assert_eq!( - widget.current_model(), - Some(&Model { - display_name: "resumed-model".to_string(), - ..resumed_model - }) + live_blob, resume_blob, + "live and resume error cells diverged" ); } #[test] -fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() { +fn startup_header_mascot_animation_advances_on_pre_draw_tick() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -1764,376 +1758,306 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - 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: Some("Resumed".to_string()), - model: Some("test-model".to_string()), - thinking: None, - reasoning_effort: None, - total_input_tokens: 12, - total_output_tokens: 18, - total_cache_read_tokens: 4, - last_query_total_tokens: 42, - last_query_input_tokens: 42, - prompt_token_estimate: 12, - history_items: Vec::new(), - loaded_item_count: 0, - pending_texts: vec![], - }); + assert_eq!(widget.startup_header_mascot_frame_index(), 0); - let idle_summary = widget.status_summary_text(); - assert!(idle_summary.contains("↑12")); - assert!(idle_summary.contains("↺4 33%")); - assert!(idle_summary.contains("↓18")); - assert!(idle_summary.contains("42/190k")); + widget.force_startup_header_animation_due(); + widget.pre_draw_tick(); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::UsageUpdated { - total_input_tokens: 7, - total_output_tokens: 2, - total_cache_read_tokens: 6, - last_query_total_tokens: 9, - last_query_input_tokens: 7, - }); + assert_eq!(widget.startup_header_mascot_frame_index(), 1); +} - let busy_summary = widget.status_summary_text(); - assert!(busy_summary.contains("↑7")); - assert!(busy_summary.contains("↺6 86%")); - assert!(busy_summary.contains("7/190k")); +#[test] +fn onboarding_view_is_active_on_first_run() { + 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 (_widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); + // Onboarding view is pushed onto the view stack on first run. + // The UI is now managed by the OnboardingView via the bottom pane view stack. +} - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "stop".to_string(), - turn_count: 2, - total_input_tokens: 19, - total_output_tokens: 20, - total_cache_read_tokens: 6, - last_query_total_tokens: 9, - last_query_input_tokens: 7, - prompt_token_estimate: 7, +#[test] +fn onboarding_validation_succeeded_clears_active_state() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "anthropic-messages-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); + + // Simulate validation success from the worker. + widget.handle_worker_event(crate::events::WorkerEvent::ProviderValidationSucceeded { + reply_preview: "OK".to_string(), }); - let finished_summary = widget.status_summary_text(); - assert!(finished_summary.contains("↑19")); - assert!(finished_summary.contains("↺6 32%")); - assert!(finished_summary.contains("7/190k")); + // After validation, placeholder should be reset to default. + assert_eq!(widget.placeholder_text(), "Ask Devo"); } #[test] -fn streaming_controller_is_initialized_and_commit_ticks_drain_lines() { +fn streamed_lines_stay_in_live_viewport_until_turn_finishes() { 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 widget, _app_event_rx) = widget_with_model(model.clone(), cwd); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - assert!(!widget.has_stream_controller()); + let base_height = widget.desired_height(80); + for index in 0..12 { + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta(format!( + "line {index}\n" + ))); + } - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "first line\nsecond line\n".to_string(), - )); - assert!(widget.has_stream_controller()); + assert!(widget.desired_height(80) > base_height); - widget.pre_draw_tick(); - let first_pass = rendered_rows(&widget, 80, 12).join("\n"); - assert!(first_pass.contains("first line")); - assert!(first_pass.contains("second line")); - let first_scrollback = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); - assert!(!first_scrollback.contains("first line")); + let committed_before_finish = widget.drain_scrollback_lines(80); + let committed_before_finish_text = committed_before_finish + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!committed_before_finish_text.contains("line 0")); + assert!(!committed_before_finish_text.contains("line 11")); - widget.pre_draw_tick(); - let second_pass = rendered_rows(&widget, 80, 12).join("\n"); - assert!(second_pass.contains("second line")); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "stop".to_string(), + turn_count: 1, + 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, + }); + + let committed_after_finish = widget.drain_scrollback_lines(80); + let committed_after_finish_text = committed_after_finish + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(committed_after_finish_text.contains("line 0")); + assert!(committed_after_finish_text.contains("line 11")); } #[test] -fn new_session_prepared_appends_header_after_existing_history_and_resets_status() { - let initial_cwd = std::env::current_dir().expect("current directory is available"); - let resumed_cwd = initial_cwd.join("resumed"); +fn committed_history_drains_to_scrollback_lines() { + let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { - slug: "initial-model".to_string(), - display_name: "Initial Model".to_string(), + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd.clone()); + let (mut widget, _app_event_rx) = widget_with_model(model.clone(), cwd.clone()); - widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { - session_id: "session-1".to_string(), - cwd: resumed_cwd, - title: None, - model: Some("resumed-model".to_string()), - thinking: None, - reasoning_effort: None, - total_input_tokens: 30, - total_output_tokens: 5, - total_cache_read_tokens: 12, - last_query_total_tokens: 25, - last_query_input_tokens: 20, - prompt_token_estimate: 20, - history_items: Vec::new(), - loaded_item_count: 0, - pending_texts: vec![], - }); - widget.add_to_history(crate::history_cell::new_info_event( - "old session line".to_string(), - None, - )); + let initial_lines = widget.drain_scrollback_lines(80); + assert!(!initial_lines.is_empty()); - widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { - cwd: initial_cwd.clone(), - model: "new-session-model".to_string(), - thinking: None, - reasoning_effort: None, - last_query_total_tokens: 25, - last_query_input_tokens: 20, - total_cache_read_tokens: 12, + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "done".to_string(), + turn_count: 1, + total_input_tokens: 10, + total_output_tokens: 20, + total_cache_read_tokens: 0, + last_query_total_tokens: 30, + last_query_input_tokens: 10, + prompt_token_estimate: 10, }); - assert_eq!(widget.current_cwd(), initial_cwd.as_path()); - assert_eq!( - widget.current_model().map(|model| model.slug.as_str()), - Some("new-session-model") + let committed_lines = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + // TurnSummaryCell is now added on TurnFinished, so scrollback is non-empty. + assert!( + !committed_lines.is_empty(), + "TurnSummaryCell should be committed" ); - - let summary = widget.status_summary_text(); - assert!(summary.contains("↑0")); - assert!(summary.contains("↺0 0%")); - assert!(summary.contains("↓0")); - assert!(summary.contains("0/190k")); - - let transcript_lines = scrollback_plain_lines( - &widget - .transcript_overlay_lines(80) - .into_iter() - .map(crate::history_cell::ScrollbackLine::new) - .collect::>(), + assert!( + committed_lines.iter().any(|line| { + line.line + .spans + .iter() + .any(|span| span.content.contains("▣")) + }), + "expected ▣ symbol in turn summary" ); - let transcript_text = transcript_lines.join("\n"); - assert!(transcript_text.contains("old session line")); - let old_line_index = find_row_index(&transcript_lines, "old session line") - .expect("old session line remains in transcript"); - let header_index = - find_row_index(&transcript_lines, "Devo").expect("new session header is appended"); - assert!(header_index > old_line_index); } #[test] -fn new_session_prepared_does_not_duplicate_startup_header_without_history() { +fn streamed_history_stays_empty_until_turn_finishes() { 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 (mut widget, _app_event_rx) = widget_with_model(model.clone(), cwd.clone()); - widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { - cwd, - model: "new-session-model".to_string(), - thinking: None, - reasoning_effort: None, - last_query_total_tokens: 10, - last_query_input_tokens: 10, - total_cache_read_tokens: 4, - }); + let _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "first\nsecond\n".to_string(), + )); - let rows = rendered_rows(&widget, 80, 16); - assert_eq!(rows.iter().filter(|row| row.contains("Devo")).count(), 1); - assert!(widget.status_summary_text().contains("↺0 0%")); + let committed_lines = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + assert!(committed_lines.is_empty()); } #[test] -fn model_selection_updates_session_projection_and_emits_context_override() { +fn batched_history_inserts_separator_and_trailing_blank_lines() { 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 alt_model = Model { - slug: "second-model".to_string(), - display_name: "Second Model".to_string(), - thinking_capability: ThinkingCapability::Levels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), - ..Model::default() - }; - let (app_event_tx, mut 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, Some(model.clone())), - initial_thinking_selection: None, - initial_permission_preset: devo_protocol::PermissionPreset::Default, - initial_user_message: None, - enhanced_keys_supported: true, - is_first_run: false, - available_models: vec![model, alt_model.clone()], - saved_model_slugs: vec!["test-model".into(), "second-model".into()], - show_model_onboarding: false, - startup_tooltip_override: None, - initial_theme_name: None, - }); - - 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()); + let (mut widget, _app_event_rx) = widget_with_model(model.clone(), cwd.clone()); - assert_eq!(widget.current_model(), Some(&alt_model)); - assert_eq!( - app_event_rx - .try_recv() - .expect("context override command is emitted"), - AppEvent::Command(AppCommand::OverrideTurnContext { - cwd: None, - model: Some("second-model".to_string()), - thinking: Some(Some("high".to_string())), - sandbox: None, - approval_policy: None, + let _ = widget.drain_scrollback_lines(80); + widget.add_to_history(crate::history_cell::new_info_event( + "first".to_string(), + None, + )); + widget.add_to_history(crate::history_cell::new_info_event( + "second".to_string(), + None, + )); + + let committed_lines = widget.drain_scrollback_lines(80); + let blank_lines = committed_lines + .iter() + .filter(|line| { + line.line + .spans + .iter() + .all(|span| span.content.trim().is_empty()) }) - ); + .count(); + assert_eq!( - app_event_rx.try_recv().expect("command event is emitted"), - AppEvent::Command(AppCommand::UserTurn { - input: vec![InputItem::Text { - text: "hello".to_string(), - }], - cwd: Some(widget.current_cwd().to_path_buf()), - model: Some("second-model".to_string()), - thinking: Some("high".to_string()), - sandbox: None, - approval_policy: Some("on-request".to_string()), - }) + 2, blank_lines, + "unexpected blank lines: {committed_lines:?}" ); } #[test] -fn model_selection_with_thinking_support_waits_for_second_step() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn session_switch_restores_header_and_spacing_before_user_input() { + let initial_cwd = std::env::current_dir().expect("current directory is available"); + let resumed_cwd = initial_cwd.join("resumed"); let model = Model { - slug: "test-model".to_string(), - display_name: "Test Model".to_string(), - ..Model::default() - }; - let alt_model = Model { - slug: "second-model".to_string(), - display_name: "Second Model".to_string(), - thinking_capability: ThinkingCapability::Levels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), + slug: "initial-model".to_string(), + display_name: "Initial Model".to_string(), ..Model::default() }; - let (app_event_tx, mut 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, Some(model)), - initial_thinking_selection: None, - initial_permission_preset: devo_protocol::PermissionPreset::Default, - initial_user_message: None, - enhanced_keys_supported: true, - is_first_run: false, - available_models: vec![alt_model.clone()], - saved_model_slugs: vec!["second-model".into()], - show_model_onboarding: false, - startup_tooltip_override: None, - initial_theme_name: None, - }); + let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd); - widget.handle_app_event(AppEvent::ModelSelected { - model: "second-model".to_string(), + let _ = widget.drain_scrollback_lines(80); + widget.add_to_history(crate::history_cell::new_info_event( + "session 1 lingering line".to_string(), + None, + )); + let _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: resumed_cwd.clone(), + title: Some("Resumed".to_string()), + model: Some("resumed-model".to_string()), + thinking: None, + reasoning_effort: None, + total_input_tokens: 3, + total_output_tokens: 5, + total_cache_read_tokens: 0, + last_query_total_tokens: 8, + last_query_input_tokens: 3, + prompt_token_estimate: 3, + history_items: vec![ + crate::events::TranscriptItem::new( + crate::events::TranscriptItemKind::User, + String::new(), + "hello".to_string(), + ), + crate::events::TranscriptItem::new( + crate::events::TranscriptItemKind::Assistant, + String::new(), + "world".to_string(), + ), + ], + rich_history_items: Vec::new(), + loaded_item_count: 2, + pending_texts: vec![], }); - 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() - .expect("context override command is emitted"), - AppEvent::Command(AppCommand::OverrideTurnContext { - cwd: None, - model: Some("second-model".to_string()), - thinking: Some(Some("high".to_string())), - sandbox: None, - approval_policy: None, + let committed_lines = widget.drain_scrollback_lines(80); + let committed_text = committed_lines + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + let committed_rows = committed_lines + .iter() + .map(|line| { + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() }) + .collect::>(); + + // The header box is rendered only once on initial launch, not on session switch. + assert_eq!(0, committed_text.matches("directory:").count()); + assert!(committed_text.contains("hello")); + assert!(committed_text.contains("world")); + assert!(!committed_text.contains("session 1 lingering line")); + assert!( + committed_rows + .windows(5) + .any(|window| window[0].trim_end() == "▌" + && window[1].contains("hello") + && window[2].trim_end() == "▌" + && window[3].trim().is_empty() + && window[4].contains("world")), + "expected restored spaced user prompt before assistant response: {committed_lines:?}" ); } #[test] -fn model_selection_without_thinking_support_finishes_immediately() { +fn turn_finished_does_not_add_completion_status_line_to_history() { let cwd = std::env::current_dir().expect("current directory is available"); - let base_model = Model { + let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let alt_model = Model { - slug: "plain-model".to_string(), - display_name: "Plain Model".to_string(), - thinking_capability: ThinkingCapability::Unsupported, - ..Model::default() - }; - let (app_event_tx, mut 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, Some(base_model)), - initial_thinking_selection: None, - initial_permission_preset: devo_protocol::PermissionPreset::Default, - initial_user_message: None, - enhanced_keys_supported: true, - is_first_run: false, - available_models: vec![alt_model.clone()], - saved_model_slugs: vec!["plain-model".into()], - show_model_onboarding: false, - startup_tooltip_override: None, - initial_theme_name: None, - }); + let (mut widget, _app_event_rx) = widget_with_model(model.clone(), cwd.clone()); - widget.handle_app_event(AppEvent::ModelSelected { - model: "plain-model".to_string(), + let _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + 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, }); - assert_eq!(widget.current_model(), Some(&alt_model)); - assert_eq!( - app_event_rx - .try_recv() - .expect("context override command is emitted"), - AppEvent::Command(AppCommand::OverrideTurnContext { - cwd: None, - model: Some("plain-model".to_string()), - thinking: Some(None), - sandbox: None, - approval_policy: None, - }) - ); + let committed_lines = widget.drain_scrollback_lines(80); + assert!(!committed_lines.iter().any(|line| { + line.line + .spans + .iter() + .any(|span| span.content.contains("Turn completed (Completed)")) + })); } #[test] -fn flushed_assistant_lines_after_reasoning_are_in_one_cell() { +fn completed_turn_summary_keeps_duration_for_text_turns() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -2142,34 +2066,9 @@ fn flushed_assistant_lines_after_reasoning_are_in_one_cell() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - // Activate reasoning pause - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( - "thinking".to_string(), - )); - // Queue assistant lines while reasoning is active - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "line one\nline two\nline three\n".to_string(), - )); - // Complete reasoning; assistant stays active until its own item or turn completes. - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( - "thinking".to_string(), - )); - - let committed = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); - let committed_text = committed - .iter() - .flat_map(|l| l.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - assert!(committed_text.contains("thinking")); - assert!(!committed_text.contains("line one")); - + let _ = widget.drain_scrollback_lines(80); + widget.force_task_elapsed_seconds(3); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta("hello".to_string())); widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { stop_reason: "Completed".to_string(), turn_count: 1, @@ -2181,209 +2080,2187 @@ fn flushed_assistant_lines_after_reasoning_are_in_one_cell() { prompt_token_estimate: 0, }); - let committed = widget.drain_scrollback_lines(80); - let non_blank: Vec<&crate::history_cell::ScrollbackLine> = committed - .iter() - .filter(|l| { - !l.line + let committed = widget + .drain_scrollback_lines(80) + .into_iter() + .map(|line| { + line.line .spans .iter() - .all(|span| span.content.trim().is_empty()) + .map(|span| span.content.as_ref()) + .collect::() }) - .collect(); - let text = non_blank + .collect::>() + .join("\n"); + + assert!(committed.contains("▣")); + assert!(committed.contains("Test Model")); + assert!(committed.contains("3s")); +} + +#[test] +fn active_response_renders_generating_status_without_devo_title() { + 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 _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta("hello".to_string())); + + let rendered = rendered_rows(&widget, 80, 12).join("\n"); + assert!(!rendered.contains("Devo -")); +} + +#[test] +fn streaming_pending_ai_reply_respects_wrap_limit_before_finalize() { + 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_app_event(AppEvent::ClearTranscript); + let _ = widget.drain_scrollback_lines(80); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "see https://example.test/path/abcdef12345 tail words".to_string(), + )); + + let rendered = rendered_rows(&widget, 24, 12).join("\n"); + assert!( + rendered.contains("tail words"), + "expected pending streaming reply to wrap suffix words together, got:\n{rendered}" + ); +} + +#[test] +fn active_assistant_markdown_does_not_double_wrap() { + 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 body = format!("{} betabet gamma", ["alpha"; 12].join(" ")); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta(body)); + + let rendered = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + rendered.contains("betabet gamma"), + "expected active assistant markdown to keep trailing words together, got:\n{rendered}" + ); +} + +#[test] +fn active_assistant_multiline_text_has_no_extra_blank_rows() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "Line1\nLine2\nLine3\n".to_string(), + )); + + let rows = rendered_rows(&widget, 80, 12); + let line1 = find_row_index(&rows, "Line1").expect("missing Line1"); + let line2 = find_row_index(&rows, "Line2").expect("missing Line2"); + let line3 = find_row_index(&rows, "Line3").expect("missing Line3"); + assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); + assert_eq!(line3, line2 + 1, "unexpected rows:\n{}", rows.join("\n")); +} + +#[test] +fn active_assistant_renders_resume_like_markdown_without_fragment_gaps() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "## devo-cli -- Binary entry point that assembles all crates\n\n".to_string(), + )); + widget.pre_draw_tick(); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "4 source files, produces the devo binary.\n\n".to_string(), + )); + widget.pre_draw_tick(); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "Command dispatch (/crates/cli/src/main.rs)\n\n".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "devo -> run_agent() interactive TUI (default)\n".to_string(), + )); + + let rows = rendered_rows(&widget, 180, 24); + let indices = indices_containing( + &rows, + &[ + "devo-cli", + "4 source files", + "Command dispatch", + "run_agent", + ], + ); + + assert_eq!( + indices + .windows(2) + .map(|pair| pair[1] - pair[0]) + .collect::>(), + vec![2, 2, 2], + "expected active assistant markdown blocks to have one separator row, not doubled gaps:\n{}", + rows.join("\n") + ); +} + +#[test] +fn committed_assistant_markdown_does_not_double_wrap() { + 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 body = format!("{} betabet gamma", ["alpha"; 12].join(" ")); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta(body)); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + 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, + }); + + let committed = widget + .drain_scrollback_lines(80) + .into_iter() + .map(|line| { + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + committed.contains("betabet gamma"), + "expected committed assistant markdown to keep trailing words together, got:\n{committed}" + ); +} + +#[test] +fn committed_assistant_multiline_text_has_no_extra_blank_rows() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "Line1\nLine2\nLine3\n".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + 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, + }); + + let lines = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( + widget.drain_scrollback_lines(80), + )); + let line1 = lines .iter() - .flat_map(|l| l.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - assert!(text.contains("line one")); + .position(|line| line.contains("Line1")) + .unwrap(); + let line2 = lines + .iter() + .position(|line| line.contains("Line2")) + .unwrap(); + let line3 = lines + .iter() + .position(|line| line.contains("Line3")) + .unwrap(); + assert_eq!(line2, line1 + 1, "unexpected lines:\n{}", lines.join("\n")); + assert_eq!(line3, line2 + 1, "unexpected lines:\n{}", lines.join("\n")); +} + +#[test] +fn tool_call_start_and_finish_are_both_visible_in_history() { + 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 _ = widget.drain_scrollback_lines(80); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "powershell -NoProfile -Command Get-Date".to_string(), + parsed_commands: None, + }); + + let running = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + running.contains("Running powershell -NoProfile -Command Get-Date"), + "expected running tool cell, got:\n{running}" + ); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "powershell -NoProfile -Command Get-Date".to_string(), + preview: "2026-05-09".to_string(), + is_error: false, + truncated: false, + }); + + let ran = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + ran.contains("Ran powershell -NoProfile -Command Get-Date"), + "expected ran tool cell, got:\n{ran}" + ); + assert!( + ran.contains("2026-05-09"), + "expected tool output, got:\n{ran}" + ); +} + +#[test] +fn reasoning_text_commits_to_history_when_turn_finishes() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + "thinking text\n".to_string(), + )); + + let empty_scrollback = widget.drain_scrollback_lines(80); + assert!(!scrollback_contains_text( + &empty_scrollback, + "thinking text" + )); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "stop".to_string(), + turn_count: 1, + 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, + }); + + let scrollback = widget.drain_scrollback_lines(80); + assert!(scrollback_contains_text(&scrollback, "thinking text")); +} + +#[test] +fn restored_reasoning_text_is_visible_in_transcript() { + 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()); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd, + 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![crate::events::TranscriptItem::new( + crate::events::TranscriptItemKind::Reasoning, + "", + "thinking text", + )], + rich_history_items: Vec::new(), + loaded_item_count: 1, + pending_texts: vec![], + }); + + let scrollback = widget.drain_scrollback_lines(80); + assert!(scrollback_contains_text(&scrollback, "thinking text")); +} + +#[test] +fn reasoning_and_assistant_stream_in_separate_cells() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + "thinking".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "final answer line 1\nfinal answer line 2\n".to_string(), + )); + + let before_rows = rendered_rows(&widget, 80, 16); + let before = before_rows.join("\n"); + assert!( + before.contains("thinking") && before.contains("final answer line 1"), + "reasoning/text should both be visible while streaming:\n{before}" + ); + let reasoning_row = find_row_index(&before_rows, "thinking").expect("missing reasoning row"); + let assistant_row = + find_row_index(&before_rows, "final answer line 1").expect("missing assistant row"); + assert_eq!( + assistant_row, + reasoning_row + 2, + "expected one blank row between live cells" + ); + assert!( + before_rows[reasoning_row + 1].trim().is_empty(), + "expected blank separator row, got: {:?}", + before_rows[reasoning_row + 1] + ); + + widget.pre_draw_tick(); + let committed_before_reasoning_complete = + trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + assert!( + !scrollback_contains_text(&committed_before_reasoning_complete, "final answer line 1"), + "assistant output should stay live, not drain to scrollback while reasoning is pending" + ); + let active_before_reasoning_complete = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + active_before_reasoning_complete.contains("final answer line 1"), + "assistant output should remain visible in the active viewport:\n{active_before_reasoning_complete}" + ); + + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( + "thinking".to_string(), + )); + + // Reasoning is now committed to scrollback on ReasoningCompleted, + // no longer visible in the live viewport. + let after = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + !after.contains("thinking"), + "reasoning text should commit to scrollback, not remain in viewport:\n{after}" + ); + + let committed_after_reasoning_complete = + trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + let committed_after_text = committed_after_reasoning_complete + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!( + committed_after_text.contains("thinking"), + "reasoning text should be in scrollback after ReasoningCompleted: {committed_after_reasoning_complete:?}" + ); + let after_reasoning_rows = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + after_reasoning_rows.contains("final answer line 2"), + "undrained assistant output should remain active after reasoning completes:\n{after_reasoning_rows}" + ); +} + +#[test] +fn lifecycle_text_items_render_as_ordered_sibling_cells() { + 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 reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "thinking".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + delta: "Line1\nLine2\n".to_string(), + }); + + let rows = rendered_rows(&widget, 80, 16); + let reasoning_row = find_row_index(&rows, "thinking").expect("missing reasoning row"); + let line1 = find_row_index(&rows, "Line1").expect("missing assistant row"); + let line2 = find_row_index(&rows, "Line2").expect("missing second assistant row"); + assert_eq!( + line1, + reasoning_row + 2, + "unexpected rows:\n{}", + rows.join("\n") + ); + assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); + + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + final_text: "thinking".to_string(), + }); + let rows_after_reasoning = rendered_rows(&widget, 80, 16); + assert!( + !rows_after_reasoning + .iter() + .any(|row| row.contains("thinking")), + "completed reasoning should leave active viewport:\n{}", + rows_after_reasoning.join("\n") + ); + assert!( + rows_after_reasoning.iter().any(|row| row.contains("Line1")), + "assistant should remain active:\n{}", + rows_after_reasoning.join("\n") + ); +} + +#[test] +fn lifecycle_text_items_keep_reasoning_before_assistant_when_events_arrive_out_of_order() { + 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 reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + delta: "answer line\n".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "thinking text".to_string(), + }); + + let rows = rendered_rows(&widget, 80, 16); + let reasoning_row = find_row_index(&rows, "thinking text").expect("missing reasoning row"); + let assistant_row = find_row_index(&rows, "answer line").expect("missing assistant row"); + assert!( + reasoning_row < assistant_row, + "reasoning should render above assistant:\n{}", + rows.join("\n") + ); + + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + final_text: "answer line".to_string(), + }); + let committed_before_reasoning = widget.drain_scrollback_lines(80); + assert!( + !scrollback_contains_text(&committed_before_reasoning, "answer line"), + "assistant should wait for prior reasoning before committing: {committed_before_reasoning:?}" + ); + + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + final_text: "thinking text".to_string(), + }); + let committed = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( + widget.drain_scrollback_lines(80), + )) + .join("\n"); + let reasoning_index = committed + .find("thinking text") + .expect("missing committed reasoning"); + let assistant_index = committed + .find("answer line") + .expect("missing committed assistant"); + assert!( + reasoning_index < assistant_index, + "reasoning should commit before assistant:\n{committed}" + ); +} + +#[test] +fn assistant_stream_commit_tick_runs_while_reasoning_is_pending() { + 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 reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "thinking text".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + delta: "first line\nsecond line\n".to_string(), + }); + + widget.pre_draw_tick(); + let committed = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + let active = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + !committed.contains("first line"), + "assistant stream should stay out of scrollback until completion:\n{committed}" + ); + assert!( + active.contains("first line"), + "assistant stream should remain visible even with pending reasoning:\n{active}" + ); +} + +// TODO: Still buggy here, need to be fixed. +// #[test] +// fn slash_popup_shows_active_filter_hint() { +// 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_paste("/m".to_string()); + +// let rendered = rendered_rows(&widget, 80, 6).join("\n"); +// assert!(rendered.contains("filter: /m")); +// assert!(rendered.contains("/model")); +// } + +#[test] +fn slash_model_opens_model_picker_instead_of_printing_current_model() { + 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 alt_model = Model { + slug: "second-model".to_string(), + display_name: "Second Model".to_string(), + thinking_capability: ThinkingCapability::Levels(vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + default_reasoning_effort: Some(ReasoningEffort::High), + ..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, Some(model.clone())), + initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: vec![model, alt_model], + saved_model_slugs: vec!["test-model".into(), "second-model".into()], + show_model_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + }); + + widget.handle_app_event(AppEvent::RunSlashCommand { + command: "model".to_string(), + }); + + assert_eq!(widget.placeholder_text(), "Ask Devo"); + assert_eq!( + widget.current_model().map(|m| m.slug.as_str()), + Some("test-model") + ); +} + +#[test] +fn session_switch_updates_session_identity_projection() { + let initial_cwd = std::env::current_dir().expect("current directory is available"); + let resumed_cwd = initial_cwd.join("resumed"); + let model = Model { + slug: "initial-model".to_string(), + display_name: "Initial Model".to_string(), + ..Model::default() + }; + let resumed_model = Model { + slug: "resumed-model".to_string(), + display_name: "Resumed Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: resumed_cwd.clone(), + title: Some("Resumed".to_string()), + model: Some("resumed-model".to_string()), + thinking: None, + reasoning_effort: None, + total_input_tokens: 3, + total_output_tokens: 5, + total_cache_read_tokens: 0, + last_query_total_tokens: 8, + last_query_input_tokens: 3, + prompt_token_estimate: 3, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: vec![], + }); + + assert_eq!(widget.current_cwd(), resumed_cwd.as_path()); + assert_eq!( + widget.current_model(), + Some(&Model { + display_name: "resumed-model".to_string(), + ..resumed_model + }) + ); +} + +#[test] +fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() { + 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::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + thinking: None, + reasoning_effort: None, + total_input_tokens: 12, + total_output_tokens: 18, + total_cache_read_tokens: 4, + last_query_total_tokens: 42, + last_query_input_tokens: 42, + prompt_token_estimate: 12, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: vec![], + }); + + let idle_summary = widget.status_summary_text(); + assert!(idle_summary.contains("↑12")); + assert!(idle_summary.contains("↺4 33%")); + assert!(idle_summary.contains("↓18")); + assert!(idle_summary.contains("42/190k")); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::UsageUpdated { + total_input_tokens: 7, + total_output_tokens: 2, + total_cache_read_tokens: 6, + last_query_total_tokens: 9, + last_query_input_tokens: 7, + }); + + let busy_summary = widget.status_summary_text(); + assert!(busy_summary.contains("↑7")); + assert!(busy_summary.contains("↺6 86%")); + assert!(busy_summary.contains("7/190k")); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "stop".to_string(), + turn_count: 2, + total_input_tokens: 19, + total_output_tokens: 20, + total_cache_read_tokens: 6, + last_query_total_tokens: 9, + last_query_input_tokens: 7, + prompt_token_estimate: 7, + }); + + let finished_summary = widget.status_summary_text(); + assert!(finished_summary.contains("↑19")); + assert!(finished_summary.contains("↺6 32%")); + assert!(finished_summary.contains("7/190k")); +} + +#[test] +fn streaming_controller_is_initialized_and_commit_ticks_drain_lines() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + assert!(!widget.has_stream_controller()); + + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "first line\nsecond line\n".to_string(), + )); + assert!(widget.has_stream_controller()); + + widget.pre_draw_tick(); + let first_pass = rendered_rows(&widget, 80, 12).join("\n"); + assert!(first_pass.contains("first line")); + assert!(first_pass.contains("second line")); + let first_scrollback = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!(!first_scrollback.contains("first line")); + + widget.pre_draw_tick(); + let second_pass = rendered_rows(&widget, 80, 12).join("\n"); + assert!(second_pass.contains("second line")); +} + +#[test] +fn new_session_prepared_appends_header_after_existing_history_and_resets_status() { + let initial_cwd = std::env::current_dir().expect("current directory is available"); + let resumed_cwd = initial_cwd.join("resumed"); + let model = Model { + slug: "initial-model".to_string(), + display_name: "Initial Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd.clone()); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: resumed_cwd, + title: None, + model: Some("resumed-model".to_string()), + thinking: None, + reasoning_effort: None, + total_input_tokens: 30, + total_output_tokens: 5, + total_cache_read_tokens: 12, + last_query_total_tokens: 25, + last_query_input_tokens: 20, + prompt_token_estimate: 20, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: vec![], + }); + widget.add_to_history(crate::history_cell::new_info_event( + "old session line".to_string(), + None, + )); + + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { + cwd: initial_cwd.clone(), + model: "new-session-model".to_string(), + thinking: None, + reasoning_effort: None, + last_query_total_tokens: 25, + last_query_input_tokens: 20, + total_cache_read_tokens: 12, + }); + + assert_eq!(widget.current_cwd(), initial_cwd.as_path()); + assert_eq!( + widget.current_model().map(|model| model.slug.as_str()), + Some("new-session-model") + ); + + let summary = widget.status_summary_text(); + assert!(summary.contains("↑0")); + assert!(summary.contains("↺0 0%")); + assert!(summary.contains("↓0")); + assert!(summary.contains("0/190k")); + + let transcript_lines = scrollback_plain_lines( + &widget + .transcript_overlay_lines(80) + .into_iter() + .map(crate::history_cell::ScrollbackLine::new) + .collect::>(), + ); + let transcript_text = transcript_lines.join("\n"); + assert!(transcript_text.contains("old session line")); + let old_line_index = find_row_index(&transcript_lines, "old session line") + .expect("old session line remains in transcript"); + let header_index = + find_row_index(&transcript_lines, "Devo").expect("new session header is appended"); + assert!(header_index > old_line_index); +} + +#[test] +fn new_session_prepared_does_not_duplicate_startup_header_without_history() { + 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()); + + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { + cwd, + model: "new-session-model".to_string(), + thinking: None, + reasoning_effort: None, + last_query_total_tokens: 10, + last_query_input_tokens: 10, + total_cache_read_tokens: 4, + }); + + let rows = rendered_rows(&widget, 80, 16); + assert_eq!(rows.iter().filter(|row| row.contains("Devo")).count(), 1); + assert!(widget.status_summary_text().contains("↺0 0%")); +} + +#[test] +fn model_selection_updates_session_projection_and_emits_context_override() { + 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 alt_model = Model { + slug: "second-model".to_string(), + display_name: "Second Model".to_string(), + thinking_capability: ThinkingCapability::Levels(vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + default_reasoning_effort: Some(ReasoningEffort::High), + ..Model::default() + }; + let (app_event_tx, mut 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, Some(model.clone())), + initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: vec![model, alt_model.clone()], + saved_model_slugs: vec!["test-model".into(), "second-model".into()], + show_model_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + }); + + 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)); + assert_eq!( + app_event_rx + .try_recv() + .expect("context override command is emitted"), + AppEvent::Command(AppCommand::OverrideTurnContext { + cwd: None, + model: Some("second-model".to_string()), + thinking: Some(Some("high".to_string())), + sandbox: None, + approval_policy: None, + }) + ); + assert_eq!( + app_event_rx.try_recv().expect("command event is emitted"), + AppEvent::Command(AppCommand::UserTurn { + input: vec![InputItem::Text { + text: "hello".to_string(), + }], + cwd: Some(widget.current_cwd().to_path_buf()), + model: Some("second-model".to_string()), + thinking: Some("high".to_string()), + sandbox: None, + approval_policy: Some("on-request".to_string()), + }) + ); +} + +#[test] +fn model_selection_with_thinking_support_waits_for_second_step() { + 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 alt_model = Model { + slug: "second-model".to_string(), + display_name: "Second Model".to_string(), + thinking_capability: ThinkingCapability::Levels(vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + default_reasoning_effort: Some(ReasoningEffort::High), + ..Model::default() + }; + let (app_event_tx, mut 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, Some(model)), + initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: vec![alt_model.clone()], + saved_model_slugs: vec!["second-model".into()], + show_model_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + }); + + widget.handle_app_event(AppEvent::ModelSelected { + model: "second-model".to_string(), + }); + + 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() + .expect("context override command is emitted"), + AppEvent::Command(AppCommand::OverrideTurnContext { + cwd: None, + model: Some("second-model".to_string()), + thinking: Some(Some("high".to_string())), + sandbox: None, + approval_policy: None, + }) + ); +} + +#[test] +fn model_selection_without_thinking_support_finishes_immediately() { + let cwd = std::env::current_dir().expect("current directory is available"); + let base_model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let alt_model = Model { + slug: "plain-model".to_string(), + display_name: "Plain Model".to_string(), + thinking_capability: ThinkingCapability::Unsupported, + ..Model::default() + }; + let (app_event_tx, mut 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, Some(base_model)), + initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: vec![alt_model.clone()], + saved_model_slugs: vec!["plain-model".into()], + show_model_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + }); + + widget.handle_app_event(AppEvent::ModelSelected { + model: "plain-model".to_string(), + }); + + assert_eq!(widget.current_model(), Some(&alt_model)); + assert_eq!( + app_event_rx + .try_recv() + .expect("context override command is emitted"), + AppEvent::Command(AppCommand::OverrideTurnContext { + cwd: None, + model: Some("plain-model".to_string()), + thinking: Some(None), + sandbox: None, + approval_policy: None, + }) + ); +} + +#[test] +fn flushed_assistant_lines_after_reasoning_are_in_one_cell() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + // Activate reasoning pause + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + "thinking".to_string(), + )); + // Queue assistant lines while reasoning is active + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "line one\nline two\nline three\n".to_string(), + )); + // Complete reasoning; assistant stays active until its own item or turn completes. + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( + "thinking".to_string(), + )); + + let committed = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + let committed_text = committed + .iter() + .flat_map(|l| l.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(committed_text.contains("thinking")); + assert!(!committed_text.contains("line one")); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + 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, + }); + + let committed = widget.drain_scrollback_lines(80); + let non_blank: Vec<&crate::history_cell::ScrollbackLine> = committed + .iter() + .filter(|l| { + !l.line + .spans + .iter() + .all(|span| span.content.trim().is_empty()) + }) + .collect(); + let text = non_blank + .iter() + .flat_map(|l| l.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(text.contains("line one")); assert!(text.contains("line two")); assert!(text.contains("line three")); } -#[test] -fn completed_streaming_assistant_consolidates_to_source_backed_cell() { - let cwd = std::env::current_dir().expect("current directory is available"); +#[test] +fn completed_streaming_assistant_consolidates_to_source_backed_cell() { + 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 _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "## Architecture\n\nA. Input pipeline\n\n".to_string(), + )); + widget.pre_draw_tick(); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "TuiEvent".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + 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, + }); + + let committed = widget.drain_scrollback_lines(80); + let text = committed + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!( + text.matches("Architecture").count(), + 1, + "completed assistant history should be consolidated without replay: {text}" + ); + assert!(text.contains("TuiEvent")); +} + +#[test] +fn reasoning_appears_exactly_once_after_full_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, _app_event_rx) = widget_with_model(model, cwd); + + let _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + "I am a unique thought".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "final answer\n".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( + "I am a unique thought".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "stop".to_string(), + turn_count: 1, + 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, + }); + + let scrollback = widget.drain_scrollback_lines(80); + let full_text = scrollback + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!( + full_text.matches("I am a unique thought").count(), + 1, + "reasoning should appear exactly once in scrollback, got:\n{full_text}" + ); +} + +#[test] +fn live_reasoning_cell_renders_without_duplication() { + 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::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + "step by step analysis".to_string(), + )); + + let rows = rendered_rows(&widget, 80, 12); + let before = rows.join("\n"); + // Reasoning text should be visible and appear exactly once. + assert!( + before.contains("step by step analysis"), + "reasoning text should be visible:\n{before}" + ); + let occurrences = before.matches("step by step analysis").count(); + assert_eq!( + occurrences, 1, + "reasoning should appear exactly once, got {occurrences}:\n{before}" + ); +} + +#[test] +fn transcript_overlay_lines_include_full_completed_tool_output() { + 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 output = (1..=8) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "bash".to_string(), + parsed_commands: None, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "bash".to_string(), + preview: output, + is_error: false, + truncated: false, + }); + + let inline = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + inline.contains("line 1") && inline.contains("line 2"), + "inline output should include the head of the preview: {inline}" + ); + assert!( + inline.contains("ctrl + t to view transcript"), + "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}" + ); + assert!( + transcript.contains("line 5") && transcript.contains("line 8"), + "transcript output should include the full tool output: {transcript}" + ); +} + +#[test] +fn transcript_overlay_lines_include_running_tool_output_delta() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "bash".to_string(), + parsed_commands: None, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { + tool_use_id: "tool-1".to_string(), + delta: "streamed output line".to_string(), + }); + + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + transcript.contains("streamed output line"), + "transcript output should include running tool deltas: {transcript}" + ); +} + +#[test] +fn read_tool_call_renders_as_explored_group_in_viewport() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "cat foo.txt".to_string(), + parsed_commands: None, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "cat foo.txt".to_string(), + preview: "hello".to_string(), + is_error: false, + truncated: false, + }); + + let display = widget + .active_cell_display_lines_for_test(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + display.contains("Explored") || display.contains("Exploring"), + "expected explored viewport grouping: {display}" + ); + assert!( + display.contains("Read foo.txt"), + "expected read summary in explored viewport: {display}" + ); + assert!(display.contains("▌ Explored") || display.contains("▌ Exploring")); +} + +#[test] +fn glob_tool_call_renders_as_explored_group_in_viewport() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "glob **/Cargo.toml in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/Cargo.toml in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "glob **/Cargo.toml in crates".to_string(), + preview: "crates/tools/Cargo.toml".to_string(), + is_error: false, + truncated: false, + }); + + let display = widget + .active_cell_display_lines_for_test(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(display.contains("Explored") || display.contains("Exploring")); + assert!( + display.contains("List crates"), + "expected list summary, got:\n{display}" + ); +} + +#[test] +fn grep_tool_call_renders_as_explored_group_in_viewport() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), + 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()), + path: Some("crates/tui/src".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), + preview: "chatwidget.rs".to_string(), + is_error: false, + truncated: false, + }); + + let display = widget + .active_cell_display_lines_for_test(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(display.contains("Explored") || display.contains("Exploring")); + assert!( + display.contains("Search rebuild_restored_session in crates/tui/src"), + "expected search summary, got:\n{display}" + ); +} + +#[test] +fn merged_explored_group_becomes_explored_after_all_results_arrive() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-2".to_string(), + summary: "glob **/plan.rs in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/plan.rs in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "grep 'plan' in crates".to_string(), + preview: "crates/tools/src/handlers/plan.rs".to_string(), + is_error: false, + truncated: false, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-2".to_string(), + title: "glob **/plan.rs in crates".to_string(), + preview: "crates/tools/src/handlers/plan.rs".to_string(), + is_error: false, + truncated: false, + }); + + let display = widget + .active_cell_display_lines_for_test(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + display.contains("▌ Explored"), + "expected merged explored group to become completed, got:\n{display}" + ); + assert!( + !display.contains("▌ Exploring"), + "merged explored group should not stay active after all completions:\n{display}" + ); +} + +#[test] +fn live_viewport_shows_explored_group_while_active() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-2".to_string(), + summary: "glob **/plan.rs in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/plan.rs in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + + let display = widget + .active_viewport_lines_for_test(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + display.contains("▌ Exploring") || display.contains("▌ Explored"), + "live viewport should show explored exec cell:\n{display}" + ); + assert!( + display.contains("Search plan in crates"), + "live viewport should include search summary:\n{display}" + ); + assert!( + display.contains("List crates"), + "live viewport should include list summary:\n{display}" + ); +} + +#[test] +fn reasoning_start_closes_current_explored_group() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: devo_core::ItemId::new(), + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-2".to_string(), + summary: "glob **/plan.rs in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/plan.rs in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert_eq!( + transcript.matches("Explored").count() + transcript.matches("Exploring").count(), + 2, + "reasoning boundary should split explored groups:\n{transcript}" + ); +} + +#[test] +fn assistant_text_start_closes_current_explored_group() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: devo_core::ItemId::new(), + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-2".to_string(), + summary: "glob **/plan.rs in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/plan.rs in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert_eq!( + transcript.matches("Explored").count() + transcript.matches("Exploring").count(), + 2, + "assistant text boundary should split explored groups:\n{transcript}" + ); +} + +#[test] +fn merged_explored_group_stays_completed_when_tool_results_arrive_after_tool_call_completion() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-2".to_string(), + summary: "glob **/plan.rs in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/plan.rs in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "grep 'plan' in crates".to_string(), + preview: String::new(), + is_error: false, + truncated: false, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-2".to_string(), + title: "glob **/plan.rs in crates".to_string(), + preview: String::new(), + is_error: false, + truncated: false, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "grep output".to_string(), + preview: "crates/tools/src/handlers/plan.rs".to_string(), + is_error: false, + truncated: false, + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-2".to_string(), + title: "glob output".to_string(), + preview: "crates/tools/src/handlers/plan.rs".to_string(), + is_error: false, + truncated: false, + }); + + let display = widget + .active_cell_display_lines_for_test(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + display.contains("▌ Explored"), + "tool result follow-up events should not reactivate explored group:\n{display}" + ); + assert!( + !display.contains("▌ Exploring"), + "tool result follow-up events should not leave explored group active:\n{display}" + ); +} + +#[test] +fn explored_group_in_history_can_finish_late_completions() { + 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_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-2".to_string(), + summary: "glob **/plan.rs in crates".to_string(), + parsed_commands: Some(vec![ + devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "glob **/plan.rs in crates".to_string(), + path: Some("crates".to_string()), + }, + ]), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "grep 'plan' in crates".to_string(), + preview: String::new(), + is_error: false, + truncated: false, + }); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-3".to_string(), + summary: "write src/main.rs".to_string(), + parsed_commands: None, + }); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-2".to_string(), + title: "glob **/plan.rs in crates".to_string(), + preview: String::new(), + is_error: false, + truncated: false, + }); + + let history_blob = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + history_blob.contains("▌ Explored"), + "late completion should finish explored cell already flushed to history:\n{history_blob}" + ); + assert!( + !history_blob.contains("▌ Exploring"), + "flushed explored cell should not stay active after late completion:\n{history_blob}" + ); +} + +#[test] +fn auto_git_diff_trigger_matches_editing_tools_only() { + assert!(ChatWidget::should_auto_show_git_diff( + "write src/main.rs", + false + )); + assert!(ChatWidget::should_auto_show_git_diff("apply_patch", false)); + assert!(!ChatWidget::should_auto_show_git_diff("bash", false)); + assert!(!ChatWidget::should_auto_show_git_diff( + "bash echo hi > file.txt", + false + )); + assert!(!ChatWidget::should_auto_show_git_diff( + "read src/main.rs", + false + )); + assert!(!ChatWidget::should_auto_show_git_diff( + "write src/main.rs", + true + )); +} + +#[tokio::test] +async fn successful_write_tool_result_triggers_diff_event() { 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 widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); - let _ = widget.drain_scrollback_lines(80); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "write src/main.rs".to_string(), + parsed_commands: None, }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "## Architecture\n\nA. Input pipeline\n\n".to_string(), - )); - widget.pre_draw_tick(); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "TuiEvent".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "Completed".to_string(), - turn_count: 1, - 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, + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "write src/main.rs".to_string(), + preview: "updated".to_string(), + is_error: false, + truncated: false, }); - let committed = widget.drain_scrollback_lines(80); - let text = committed - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - assert_eq!( - text.matches("Architecture").count(), - 1, - "completed assistant history should be consolidated without replay: {text}" + let diff_event = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let Some(AppEvent::DiffResult(text)) = app_event_rx.recv().await { + break text; + } + } + }) + .await + .expect("diff event should arrive"); + + assert!( + !diff_event.is_empty(), + "auto diff should send some result text" ); - assert!(text.contains("TuiEvent")); } #[test] -fn reasoning_appears_exactly_once_after_full_turn() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn patch_applied_event_renders_edited_block() { 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 widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - let _ = widget.drain_scrollback_lines(80); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( - "I am a unique thought".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "final answer\n".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( - "I am a unique thought".to_string(), - )); - widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { - stop_reason: "stop".to_string(), - turn_count: 1, - 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, - }); + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("foo.txt"), + devo_protocol::protocol::FileChange::Update { + unified_diff: "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(), + move_path: None, + }, + ); - let scrollback = widget.drain_scrollback_lines(80); - let full_text = scrollback - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); - assert_eq!( - full_text.matches("I am a unique thought").count(), - 1, - "reasoning should appear exactly once in scrollback, got:\n{full_text}" + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + blob.contains("Edited foo.txt") || blob.contains("Edited 1 file"), + "expected edited patch block, got:\n{blob}" ); + assert!(blob.contains("▌ Edited") || blob.contains("▌ Added")); } #[test] -fn live_reasoning_cell_renders_without_duplication() { - let cwd = std::env::current_dir().expect("current directory is available"); +fn apply_patch_style_full_git_diff_reports_non_zero_counts() { 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 widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - thinking: None, - reasoning_effort: None, - turn_id: Default::default(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( - "step by step analysis".to_string(), - )); + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("update.txt"), + devo_protocol::protocol::FileChange::Update { + unified_diff: "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(), + move_path: None, + }, + ); - let rows = rendered_rows(&widget, 80, 12); - let before = rows.join("\n"); - // Reasoning text should be visible and appear exactly once. + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - before.contains("step by step analysis"), - "reasoning text should be visible:\n{before}" + blob.contains("(+1 -1)"), + "full git-style apply_patch diff should report non-zero counts:\n{blob}" ); - let occurrences = before.matches("step by step analysis").count(); + assert!( + !blob.contains("Edited 0 files (+0 -0)"), + "full git-style apply_patch diff should not collapse to zero summary:\n{blob}" + ); +} + +#[test] +fn diff_count_parser_handles_write_generated_update_diff_shape() { + let diff = "diff --git a/foo.txt b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n"; assert_eq!( - occurrences, 1, - "reasoning should appear exactly once, got {occurrences}:\n{before}" + crate::diff_render::calculate_add_remove_from_diff(diff), + (1, 1) ); } #[test] -fn transcript_overlay_lines_include_full_completed_tool_output() { +fn diff_count_parser_handles_apply_patch_generated_update_diff_shape() { + let diff = "diff --git a/update.txt b/update.txt\n@@ -1 +1 @@\n-old\n+new\n"; + assert_eq!( + crate::diff_render::calculate_add_remove_from_diff(diff), + (1, 1) + ); +} + +#[test] +fn write_patch_applied_event_renders_edited_block() { 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 output = (1..=8) - .map(|index| format!("line {index}")) - .collect::>() - .join("\n"); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "bash".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "bash".to_string(), - preview: output, - is_error: false, - truncated: false, - }); + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("foo.txt"), + devo_protocol::protocol::FileChange::Update { + unified_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".to_string(), + move_path: None, + }, + ); - let inline = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); - let transcript = widget - .transcript_overlay_lines(80) - .into_iter() - .map(|line| { - line.spans - .into_iter() - .map(|span| span.content.to_string()) - .collect::() - }) - .collect::>() - .join("\n"); + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - !inline.contains("line 5"), - "inline output should stay compact: {inline}" + blob.contains("Edited foo.txt") || blob.contains("Edited 1 file"), + "expected edited patch block for write result, got:\n{blob}" + ); +} + +#[test] +fn write_patch_applied_event_reports_non_zero_counts() { + 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("foo.txt"), + devo_protocol::protocol::FileChange::Update { + unified_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".to_string(), + move_path: None, + }, ); + + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - transcript.contains("line 5") && transcript.contains("line 8"), - "transcript output should include the full tool output: {transcript}" + !blob.contains("Edited 0 files (+0 -0)"), + "write-derived edited block should not collapse to zero summary:\n{blob}" + ); + assert!( + blob.contains("(+1 -1)"), + "write-derived edited block should report non-zero counts:\n{blob}" ); } #[test] -fn transcript_overlay_lines_include_running_tool_output_delta() { +fn patch_applied_event_with_diff_only_reports_non_zero_counts() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), @@ -2391,29 +4268,109 @@ fn transcript_overlay_lines_include_running_tool_output_delta() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "bash".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { - tool_use_id: "tool-1".to_string(), - delta: "streamed output line".to_string(), + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("foo.txt"), + devo_protocol::protocol::FileChange::Update { + unified_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".to_string(), + move_path: None, + }, + ); + + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + !blob.contains("Edited 0 files (+0 -0)"), + "patch-derived edited block should not collapse to zero summary:\n{blob}" + ); +} + +#[test] +fn session_switch_without_rich_edited_metadata_degrades_to_tool_result_path() { + 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::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![crate::events::TranscriptItem::restored_tool_result( + "Ran apply_patch output", + "{\"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\",\"additions\":1,\"deletions\":1}]}", + )], + rich_history_items: Vec::new(), + loaded_item_count: 1, + pending_texts: vec![], }); - let transcript = widget - .transcript_overlay_lines(80) - .into_iter() - .map(|line| { - line.spans - .into_iter() - .map(|span| span.content.to_string()) - .collect::() - }) - .collect::>() - .join("\n"); + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + blob.contains("Ran apply_patch output"), + "missing rich metadata currently falls back to tool-result rendering:\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"); + 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::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![crate::events::TranscriptItem::restored_tool_result( + "Ran apply_patch output", + "{\"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\",\"additions\":1,\"deletions\":1}]}", + )], + rich_history_items: vec![devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolResult, + title: "apply_patch output".to_string(), + body: "{\"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\",\"additions\":1,\"deletions\":1}]}".to_string(), + metadata: None, + duration_ms: None, + }], + loaded_item_count: 1, + pending_texts: vec![], + }); + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - transcript.contains("streamed output line"), - "transcript output should include running tool deltas: {transcript}" + blob.contains("Edited foo.txt") || blob.contains("Edited 1 file"), + "fallback parse should restore edited block without rich metadata:\n{blob}" + ); + assert!( + !blob.contains("Ran apply_patch output"), + "fallback parse should avoid tool-result degradation:\n{blob}" ); } diff --git a/crates/tui/src/diff_render.rs b/crates/tui/src/diff_render.rs index be751dd..b32d8fd 100644 --- a/crates/tui/src/diff_render.rs +++ b/crates/tui/src/diff_render.rs @@ -416,7 +416,7 @@ fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec> = vec!["• ".dim()]; + let mut header_spans: Vec> = vec!["▌ ".dim()]; if let [row] = &rows[..] { let verb = match &row.change { FileChange::Add { .. } => "Added", diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index f82b373..3dd478e 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; +use std::path::PathBuf; use std::time::Instant; use crate::app_command::InputHistoryDirection; @@ -5,8 +7,25 @@ use devo_core::ItemId; use devo_core::SessionId; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningEffort; +use devo_protocol::SessionHistoryItem; +use devo_protocol::parse_command::ParsedCommand; +use devo_protocol::protocol::FileChange; const TOOL_RESULT_FOLD_FINAL_STAGE: u8 = 3; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PlanStepStatus { + Pending, + InProgress, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlanStep { + pub(crate) text: String, + pub(crate) status: PlanStepStatus, +} + /// One persisted session entry shown in the interactive session picker panel. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SessionListEntry { @@ -36,7 +55,7 @@ pub struct SavedModelEntry { use devo_protocol::TurnId; /// One event emitted by the background query worker into the interactive UI. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum WorkerEvent { /// A new assistant turn has started. TurnStarted { @@ -49,6 +68,8 @@ pub(crate) enum WorkerEvent { /// The server-assigned turn identifier. turn_id: TurnId, }, + /// The active session identifier is now known. + SessionActivated { session_id: SessionId }, /// Input queue state updated by the server. InputQueueUpdated { pending_count: usize, @@ -84,6 +105,8 @@ pub(crate) enum WorkerEvent { tool_use_id: String, /// Human-readable summary line for the tool execution. summary: String, + /// Optional parsed command semantics for command-like and exploration-like tools. + parsed_commands: Option>, }, /// Incremental output delta from a running tool. ToolOutputDelta { @@ -105,6 +128,15 @@ pub(crate) enum WorkerEvent { /// Whether the preview was truncated for display. truncated: bool, }, + /// A structured patch/edit summary derived from apply_patch output. + PatchApplied { + changes: HashMap, + }, + /// A structured plan or todo list update. + PlanUpdated { + explanation: Option, + steps: Vec, + }, ApprovalRequest { session_id: SessionId, turn_id: TurnId, @@ -236,6 +268,8 @@ pub(crate) enum WorkerEvent { prompt_token_estimate: usize, /// Replay-friendly transcript items loaded from the resumed session. history_items: Vec, + /// Rich persisted history items used to rebuild semantic cells on resume. + rich_history_items: Vec, /// Number of persisted items loaded for the resumed session. loaded_item_count: u64, /// Pending turn input texts queued for the next turn. diff --git a/crates/tui/src/exec_cell/mod.rs b/crates/tui/src/exec_cell/mod.rs index 393df90..72e2b2e 100644 --- a/crates/tui/src/exec_cell/mod.rs +++ b/crates/tui/src/exec_cell/mod.rs @@ -3,8 +3,10 @@ mod render; mod spinner; pub(crate) use model::CommandOutput; +pub(crate) use model::ExecCell; pub(crate) use render::OutputLinesParams; pub(crate) use render::TOOL_CALL_MAX_LINES; +pub(crate) use render::new_active_exec_command; pub(crate) use render::output_lines; pub(crate) use render::spinner; pub(crate) use render::truncated_tool_output_preview; diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index c422e0d..5559f4b 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -185,6 +185,7 @@ pub(crate) fn truncated_tool_output_preview( preview: &str, width: u16, max_rows: usize, + line_limit: usize, ) -> Vec> { let raw_output = output_lines( Some(&CommandOutput { @@ -193,7 +194,7 @@ pub(crate) fn truncated_tool_output_preview( formatted_output: preview.to_string(), }), OutputLinesParams { - line_limit: TOOL_CALL_MAX_LINES, + line_limit, only_err: false, include_angle_pipe: false, include_prefix: false, @@ -250,6 +251,9 @@ impl HistoryCell for ExecCell { } fn transcript_lines(&self, width: u16) -> Vec> { + if self.is_exploring_cell() { + return self.exploring_display_lines(width); + } let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { if i > 0 { @@ -306,11 +310,7 @@ impl ExecCell { fn exploring_display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); out.push(Line::from(vec![ - if self.is_active() { - spinner(self.active_start_time(), self.animations_enabled()) - } else { - "•".dim() - }, + "▌".dim(), " ".into(), if self.is_active() { "Exploring".bold() diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index d0e5cd1..e9ccb6a 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -338,12 +338,14 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) }; - let mut lines: Vec> = vec![blank_prefixed_line()]; - + let mut lines = vec![blank_prefixed_line()]; if let Some(wrapped_message) = wrapped_message { - lines.extend(prefix_lines(wrapped_message, "▌ ".cyan(), "▌ ".cyan())); + lines.extend(prefix_lines( + wrapped_message, + Span::styled("▌ ", prefix_style), + Span::styled("▌ ", prefix_style), + )); } - lines.push(blank_prefixed_line()); lines } diff --git a/crates/tui/src/host.rs b/crates/tui/src/host.rs index efcec3e..23598e1 100644 --- a/crates/tui/src/host.rs +++ b/crates/tui/src/host.rs @@ -45,9 +45,11 @@ struct PendingOnboarding { #[derive(Debug, Default)] struct InteractiveLoopState { + session_id: Option, turn_count: usize, total_input_tokens: usize, total_output_tokens: usize, + total_cache_read_tokens: usize, pending_onboarding: Option, // True while the resume browser is waiting for the worker's session list. resume_browser_pending: bool, @@ -58,6 +60,7 @@ struct InteractiveLoopState { // replacement session has been restored into widget state. session_switch_pending: bool, last_ctrl_c_at: Option, + esc_backtrack_primed: bool, overlay: OverlayState, } @@ -74,6 +77,14 @@ enum CtrlCKeyAction { Exit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EscBacktrackAction { + Noop, + PrimeHint, + OpenOverlay, + ClearHint, +} + struct AppCommandContext<'a, M: ModelCatalog> { model_catalog: &'a M, default_provider: ProviderWireApi, @@ -148,6 +159,7 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result Result { + if matches!( + key.code, + KeyCode::Enter | KeyCode::Char('\n' | '\r') | KeyCode::Modifier(_) + ) || (matches!(key.code, KeyCode::Char('j' | 'm')) + && key.modifiers.contains(KeyModifiers::CONTROL)) + { + tracing::debug!( + code = ?key.code, + modifiers = ?key.modifiers, + kind = ?key.kind, + state = ?key.state, + "received enter-like key event" + ); + } // Keep Ctrl-C available for terminal copy workflows while work is // active. Cancellation is owned by the bottom pane's Esc flow. if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { @@ -384,6 +430,32 @@ fn handle_tui_event( } loop_state.last_ctrl_c_at = None; + match determine_esc_backtrack_action( + key, + loop_state.esc_backtrack_primed, + chat_widget.is_normal_backtrack_mode(), + chat_widget.composer_is_empty(), + ) { + EscBacktrackAction::PrimeHint => { + loop_state.esc_backtrack_primed = true; + chat_widget.show_esc_backtrack_hint(); + return Ok(LoopAction::Continue); + } + EscBacktrackAction::OpenOverlay => { + loop_state.esc_backtrack_primed = false; + chat_widget.clear_esc_backtrack_hint(); + loop_state.overlay.open_transcript(tui, chat_widget)?; + if let Some(transcript) = loop_state.overlay.transcript_mut() { + transcript.begin_backtrack_preview(); + } + return Ok(LoopAction::Continue); + } + EscBacktrackAction::ClearHint => { + loop_state.esc_backtrack_primed = false; + chat_widget.clear_esc_backtrack_hint(); + } + EscBacktrackAction::Noop => {} + } chat_widget.handle_key_event(key); } TuiEvent::Paste(pasted) => { @@ -416,6 +488,31 @@ fn handle_ctrl_c_key(loop_state: &mut InteractiveLoopState, now: Instant) -> Ctr CtrlCKeyAction::PromptExitConfirmation } +fn determine_esc_backtrack_action( + key: crossterm::event::KeyEvent, + esc_backtrack_primed: bool, + is_normal_backtrack_mode: bool, + composer_is_empty: bool, +) -> EscBacktrackAction { + if !matches!( + key.kind, + crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat + ) { + return EscBacktrackAction::Noop; + } + if key.code == KeyCode::Esc && is_normal_backtrack_mode && composer_is_empty { + return if esc_backtrack_primed { + EscBacktrackAction::OpenOverlay + } else { + EscBacktrackAction::PrimeHint + }; + } + if key.code != KeyCode::Esc && esc_backtrack_primed { + return EscBacktrackAction::ClearHint; + } + EscBacktrackAction::Noop +} + fn handle_app_event( app_event: Option, worker: &QueryWorkerHandle, @@ -473,32 +570,40 @@ fn handle_worker_event( turn_count: next_turn_count, total_input_tokens: next_total_input_tokens, total_output_tokens: next_total_output_tokens, + total_cache_read_tokens: next_total_cache_read_tokens, .. } | WorkerEvent::TurnFailed { turn_count: next_turn_count, total_input_tokens: next_total_input_tokens, total_output_tokens: next_total_output_tokens, + total_cache_read_tokens: next_total_cache_read_tokens, .. } => { loop_state.busy = false; loop_state.turn_count = *next_turn_count; loop_state.total_input_tokens = *next_total_input_tokens; loop_state.total_output_tokens = *next_total_output_tokens; + loop_state.total_cache_read_tokens = *next_total_cache_read_tokens; loop_state.session_switch_pending = false; } WorkerEvent::TurnStarted { .. } => { loop_state.busy = true; } + WorkerEvent::SessionActivated { session_id } => { + loop_state.session_id = Some(*session_id); + } // Streaming deltas are handled entirely within the ChatWidget WorkerEvent::ToolOutputDelta { .. } => {} WorkerEvent::UsageUpdated { total_input_tokens: next_total_input_tokens, total_output_tokens: next_total_output_tokens, + total_cache_read_tokens: next_total_cache_read_tokens, .. } => { loop_state.total_input_tokens = *next_total_input_tokens; loop_state.total_output_tokens = *next_total_output_tokens; + loop_state.total_cache_read_tokens = *next_total_cache_read_tokens; } WorkerEvent::ProviderValidationSucceeded { .. } => { if let Some(pending) = loop_state.pending_onboarding.take() { @@ -535,8 +640,18 @@ fn handle_worker_event( WorkerEvent::SessionCompactionFailed { .. } => { loop_state.busy = false; } - WorkerEvent::SessionSwitched { .. } => { + WorkerEvent::SessionSwitched { + session_id, + total_input_tokens, + total_output_tokens, + total_cache_read_tokens, + .. + } => { loop_state.session_switch_pending = false; + loop_state.session_id = devo_core::SessionId::try_from(session_id.as_str()).ok(); + loop_state.total_input_tokens = *total_input_tokens; + loop_state.total_output_tokens = *total_output_tokens; + loop_state.total_cache_read_tokens = *total_cache_read_tokens; } WorkerEvent::TextDelta(_) | WorkerEvent::TextItemStarted { .. } @@ -547,6 +662,8 @@ fn handle_worker_event( | WorkerEvent::ReasoningCompleted(_) | WorkerEvent::ToolCall { .. } | WorkerEvent::ToolResult { .. } + | WorkerEvent::PatchApplied { .. } + | WorkerEvent::PlanUpdated { .. } | WorkerEvent::SessionsListed { .. } | WorkerEvent::SkillsListed { .. } | WorkerEvent::NewSessionPrepared { .. } @@ -756,4 +873,60 @@ mod tests { assert_eq!(CtrlCKeyAction::PromptExitConfirmation, first); assert_eq!(CtrlCKeyAction::Exit, second); } + + #[test] + fn esc_backtrack_requires_second_press_to_open_overlay() { + let esc_press = crossterm::event::KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }; + let esc_release = crossterm::event::KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Release, + state: crossterm::event::KeyEventState::NONE, + }; + + assert_eq!( + determine_esc_backtrack_action( + esc_press, false, /*is_normal_backtrack_mode*/ true, + /*composer_is_empty*/ true, + ), + EscBacktrackAction::PrimeHint + ); + assert_eq!( + determine_esc_backtrack_action( + esc_release, + true, + /*is_normal_backtrack_mode*/ true, + /*composer_is_empty*/ true, + ), + EscBacktrackAction::Noop + ); + assert_eq!( + determine_esc_backtrack_action( + esc_press, true, /*is_normal_backtrack_mode*/ true, + /*composer_is_empty*/ true, + ), + EscBacktrackAction::OpenOverlay + ); + } + + #[test] + fn session_activated_updates_loop_state_session_id() { + let session_id = devo_core::SessionId::new(); + let mut loop_state = InteractiveLoopState::default(); + let worker_event = WorkerEvent::SessionActivated { session_id }; + + match &worker_event { + WorkerEvent::SessionActivated { session_id } => { + loop_state.session_id = Some(*session_id); + } + _ => unreachable!(), + } + + assert_eq!(loop_state.session_id, Some(session_id)); + } } diff --git a/crates/tui/src/host_overlay.rs b/crates/tui/src/host_overlay.rs index e8aa777..87fe7d7 100644 --- a/crates/tui/src/host_overlay.rs +++ b/crates/tui/src/host_overlay.rs @@ -70,6 +70,20 @@ impl OverlayState { Ok(()) } + pub(crate) fn transcript_mut(&mut self) -> Option<&mut TranscriptOverlay> { + match self.overlay.as_mut() { + Some(Overlay::Transcript(overlay)) => Some(overlay), + _ => None, + } + } + + pub(crate) fn close(&mut self, tui: &mut Tui) -> Result<()> { + self.overlay = None; + tui.leave_alt_screen()?; + tui.frame_requester().schedule_frame(); + Ok(()) + } + pub(crate) fn open_diff( &mut self, tui: &mut Tui, diff --git a/crates/tui/src/pager_overlay.rs b/crates/tui/src/pager_overlay.rs index d851f8f..3b016b4 100644 --- a/crates/tui/src/pager_overlay.rs +++ b/crates/tui/src/pager_overlay.rs @@ -14,6 +14,8 @@ use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::buffer::Cell as BufferCell; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -29,12 +31,39 @@ use crate::chatwidget::TranscriptOverlayCell; use crate::render::Insets; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; +use crate::style::user_message_style; use crate::tui; use crate::tui::TuiEvent; pub(crate) enum Overlay { - Transcript(TranscriptOverlay), - Static(StaticOverlay), + Transcript(Box), + Static(Box), +} + +struct UserTranscriptCellRenderable { + lines: Vec>, + style: Style, +} + +impl Renderable for UserTranscriptCellRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + for y in area.y..area.bottom() { + for x in area.x..area.right() { + buf[(x, y)].set_style(self.style); + } + } + Paragraph::new(Text::from(self.lines.clone())) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(self.lines.clone())) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } } impl fmt::Debug for Overlay { @@ -48,11 +77,11 @@ impl fmt::Debug for Overlay { impl Overlay { pub(crate) fn new_transcript(cells: Vec, width: u16) -> Self { - Self::Transcript(TranscriptOverlay::new(cells, width)) + Self::Transcript(Box::new(TranscriptOverlay::new(cells, width))) } pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { - Self::Static(StaticOverlay::new(lines, title)) + Self::Static(Box::new(StaticOverlay::new(lines, title))) } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { @@ -188,31 +217,38 @@ impl PagerView { buf: &mut Buffer, total_len: usize, ) { - if full_area.height == 0 { + if full_area.height < 2 { return; } - - let y = content_area + let first_y = content_area .bottom() - .min(full_area.bottom().saturating_sub(1)); - let rect = Rect::new(full_area.x, y, full_area.width, 1); - Span::from("-".repeat(rect.width as usize)) + .min(full_area.bottom().saturating_sub(2)); + let first = Rect::new(full_area.x, first_y, full_area.width, 1); + let second = Rect::new(full_area.x, first_y.saturating_add(1), full_area.width, 1); + Span::from("-".repeat(first.width as usize)) + .dim() + .render_ref(first, buf); + Span::from("-".repeat(second.width as usize)) .dim() - .render_ref(rect, buf); + .render_ref(second, buf); - let hints = " Q/Ctrl+C/ESC close Up/Down scroll PgUp/PgDn page "; - Span::from(hints) + let hints1 = " ↑/↓ to scroll pgup/pgdn to page home/end to jump "; + let hints2 = " q to quit esc/← to edit prev → to edit next enter to edit message "; + Span::from(hints1) .dim() - .render_ref(Rect::new(rect.x, rect.y, rect.width, 1), buf); + .render_ref(Rect::new(first.x, first.y, first.width, 1), buf); + Span::from(hints2) + .dim() + .render_ref(Rect::new(second.x, second.y, second.width, 1), buf); let percent = self.scroll_percent(total_len, content_area.height as usize); let pct_text = format!(" {percent}% "); let pct_w = pct_text.chars().count() as u16; - if rect.width > pct_w { - let pct_x = rect.x + rect.width.saturating_sub(pct_w); + if first.width > pct_w { + let pct_x = first.x + first.width.saturating_sub(pct_w); Span::from(pct_text) .dim() - .render_ref(Rect::new(pct_x, rect.y, pct_w, 1), buf); + .render_ref(Rect::new(pct_x, first.y, pct_w, 1), buf); } } @@ -289,12 +325,36 @@ impl PagerView { .max(1) } + fn scroll_chunk_into_view(&mut self, renderable_index: usize) { + let width = self + .last_content_height + .map(|_| self.content_area(Rect::new(0, 0, 80, 24)).width) + .unwrap_or(80); + let width = width.max(1); + let mut start = 0usize; + for (idx, renderable) in self.renderables.iter().enumerate() { + let height = renderable.desired_height(width) as usize; + let end = start.saturating_add(height); + if idx == renderable_index { + if self.scroll_offset > start { + self.scroll_offset = start; + } else if let Some(visible_height) = self.last_content_height + && end > self.scroll_offset.saturating_add(visible_height) + { + self.scroll_offset = end.saturating_sub(visible_height); + } + return; + } + start = end; + } + } + fn content_area(&self, area: Rect) -> Rect { Rect::new( area.x, area.y.saturating_add(1), area.width, - area.height.saturating_sub(2), + area.height.saturating_sub(3), ) } @@ -366,6 +426,7 @@ pub(crate) struct TranscriptOverlay { committed_key: CommittedCellsKey, live_tail: Option, live_tail_key: Option, + selected_user_index: Option, is_done: bool, } @@ -398,6 +459,7 @@ impl TranscriptOverlay { committed_key, live_tail: None, live_tail_key: None, + selected_user_index: None, is_done: false, } } @@ -456,6 +518,8 @@ impl TranscriptOverlay { (!lines.is_empty()).then_some(TranscriptOverlayCell { lines, is_stream_continuation: key.is_stream_continuation, + user_message: None, + is_selected_user: false, }) }); self.rebuild_renderables(); @@ -469,6 +533,90 @@ impl TranscriptOverlay { self.view.is_scrolled_to_bottom() } + pub(crate) fn begin_backtrack_preview(&mut self) { + self.selected_user_index = self.user_positions().last().copied(); + self.sync_selected_user_highlight(); + if let Some(idx) = self.selected_user_index { + self.view.scroll_chunk_into_view(idx); + } + } + + pub(crate) fn select_prev_user(&mut self) { + let positions = self.user_positions(); + if positions.is_empty() { + self.selected_user_index = None; + self.sync_selected_user_highlight(); + return; + } + self.selected_user_index = Some(match self.selected_user_index { + None => *positions.last().unwrap_or(&positions[0]), + Some(current) => positions + .iter() + .rev() + .copied() + .find(|idx| *idx < current) + .unwrap_or(positions[0]), + }); + self.sync_selected_user_highlight(); + if let Some(idx) = self.selected_user_index { + self.view.scroll_chunk_into_view(idx); + } + } + + pub(crate) fn select_next_user(&mut self) { + let positions = self.user_positions(); + if positions.is_empty() { + self.selected_user_index = None; + self.sync_selected_user_highlight(); + return; + } + self.selected_user_index = Some(match self.selected_user_index { + None => *positions.last().unwrap_or(&positions[0]), + Some(current) => positions + .iter() + .copied() + .find(|idx| *idx > current) + .unwrap_or(*positions.last().unwrap_or(¤t)), + }); + self.sync_selected_user_highlight(); + if let Some(idx) = self.selected_user_index { + self.view.scroll_chunk_into_view(idx); + } + } + + pub(crate) fn selected_user_message(&self) -> Option { + self.selected_user_index + .and_then(|idx| self.cells.get(idx)) + .and_then(|cell| cell.user_message.clone()) + } + + #[cfg(test)] + pub(crate) fn selected_user_index_for_test(&self) -> Option { + self.selected_user_index + } + + pub(crate) fn selected_user_history_position(&self) -> Option { + let positions = self.user_positions(); + self.selected_user_index + .and_then(|selected| positions.iter().position(|idx| *idx == selected)) + } + + fn user_positions(&self) -> Vec { + self.cells + .iter() + .enumerate() + .filter_map(|(idx, cell)| cell.user_message.is_some().then_some(idx)) + .collect() + } + + fn sync_selected_user_highlight(&mut self) { + for (idx, cell) in self.cells.iter_mut().enumerate() { + cell.is_selected_user = + cell.user_message.is_some() && self.selected_user_index == Some(idx); + } + self.rebuild_renderables(); + } + fn rebuild_renderables(&mut self) { self.view.renderables = Self::render_cells(&self.cells, self.live_tail.as_ref()); } @@ -488,8 +636,24 @@ impl TranscriptOverlay { } fn cell_renderable(cell: TranscriptOverlayCell, has_prior_cells: bool) -> Box { - let paragraph = Paragraph::new(Text::from(cell.lines)).wrap(Wrap { trim: false }); - let mut renderable: Box = Box::new(CachedRenderable::new(paragraph)); + let mut renderable: Box = if cell.user_message.is_some() { + let mut style = user_message_style(); + if style.bg.is_none() { + style = style.bg(Color::Rgb(40, 44, 52)); + } + if cell.is_selected_user { + style = style + .bg(Color::Rgb(52, 70, 110)) + .fg(Color::Rgb(120, 200, 255)); + } + Box::new(CachedRenderable::new(UserTranscriptCellRenderable { + lines: cell.lines, + style, + })) + } else { + let paragraph = Paragraph::new(Text::from(cell.lines)).wrap(Wrap { trim: false }); + Box::new(CachedRenderable::new(paragraph)) + }; if has_prior_cells && !cell.is_stream_continuation { renderable = Box::new(InsetRenderable::new( renderable, @@ -504,8 +668,18 @@ impl TranscriptOverlay { fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => { - if close_key(key_event) || ctrl_t_key(key_event) { + if ctrl_t_key(key_event) || close_key(key_event) { self.is_done = true; + } else if is_press_or_repeat(key_event) + && matches!(key_event.code, KeyCode::Esc | KeyCode::Left) + { + self.select_prev_user(); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } else if is_press_or_repeat(key_event) && key_event.code == KeyCode::Right { + self.select_next_user(); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); } else if self .view .handle_key_event(key_event, tui.terminal.viewport_area) @@ -616,7 +790,7 @@ fn is_press_or_repeat(key_event: KeyEvent) -> bool { fn close_key(key_event: KeyEvent) -> bool { is_press_or_repeat(key_event) - && (matches!(key_event.code, KeyCode::Char('q') | KeyCode::Esc) + && (matches!(key_event.code, KeyCode::Char('q')) || (key_event.code == KeyCode::Char('c') && key_event.modifiers.contains(KeyModifiers::CONTROL))) } @@ -627,6 +801,14 @@ fn ctrl_t_key(key_event: KeyEvent) -> bool { && key_event.modifiers.contains(KeyModifiers::CONTROL) } +#[cfg(test)] +fn transcript_hint_lines_for_test() -> (String, String) { + ( + " ↑/↓ to scroll pgup/pgdn to page home/end to jump ".to_string(), + " q to quit esc/← to edit prev → to edit next enter to edit message ".to_string(), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -647,6 +829,8 @@ mod tests { TranscriptOverlayCell { lines: vec![Line::from(text.into())], is_stream_continuation: false, + user_message: None, + is_selected_user: false, } } @@ -752,4 +936,196 @@ mod tests { assert_eq!(4, overlay.view.scroll_offset); } + + #[test] + fn transcript_overlay_backtrack_navigation_selects_user_cells() { + let mut overlay = TranscriptOverlay::new( + vec![ + TranscriptOverlayCell { + lines: vec![Line::from("assistant 1")], + is_stream_continuation: false, + user_message: None, + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("user 1")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 1")), + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("assistant 2")], + is_stream_continuation: false, + user_message: None, + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("user 2")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 2")), + is_selected_user: false, + }, + ], + 80, + ); + + overlay.begin_backtrack_preview(); + assert_eq!( + overlay.selected_user_message().map(|m| m.text), + Some("user 2".to_string()) + ); + + overlay.select_prev_user(); + assert_eq!( + overlay.selected_user_message().map(|m| m.text), + Some("user 1".to_string()) + ); + + overlay.select_next_user(); + assert_eq!( + overlay.selected_user_message().map(|m| m.text), + Some("user 2".to_string()) + ); + } + + #[test] + fn transcript_overlay_enter_target_is_latest_user_after_begin_preview() { + let mut overlay = TranscriptOverlay::new( + vec![ + TranscriptOverlayCell { + lines: vec![Line::from("user 1")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 1")), + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("user 2")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 2")), + is_selected_user: false, + }, + ], + 80, + ); + + overlay.begin_backtrack_preview(); + assert_eq!( + overlay.selected_user_message().map(|m| m.text), + Some("user 2".to_string()) + ); + } + + #[test] + fn transcript_overlay_selected_user_message_returns_full_payload() { + let mut overlay = TranscriptOverlay::new( + vec![ + TranscriptOverlayCell { + lines: vec![Line::from("user 1")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 1")), + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("user 2")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 2")), + is_selected_user: false, + }, + ], + 80, + ); + + overlay.begin_backtrack_preview(); + let selected = overlay + .selected_user_message() + .expect("selected latest user"); + assert_eq!(selected.text, "user 2"); + } + + #[test] + fn transcript_overlay_hint_lines_match_backtrack_copy() { + let (line1, line2) = transcript_hint_lines_for_test(); + assert!(line1.contains("↑/↓ to scroll")); + assert!(line1.contains("pgup/pgdn to page")); + assert!(line1.contains("home/end to jump")); + assert!(line2.contains("q to quit")); + assert!(line2.contains("esc/← to edit prev")); + assert!(line2.contains("→ to edit next")); + assert!(line2.contains("enter to edit message")); + } + + #[test] + fn transcript_overlay_selected_user_index_tracks_history_position() { + let mut overlay = TranscriptOverlay::new( + vec![ + TranscriptOverlayCell { + lines: vec![Line::from("assistant 1")], + is_stream_continuation: false, + user_message: None, + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("user 1")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 1")), + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("assistant 2")], + is_stream_continuation: false, + user_message: None, + is_selected_user: false, + }, + TranscriptOverlayCell { + lines: vec![Line::from("user 2")], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("user 2")), + is_selected_user: false, + }, + ], + 80, + ); + + overlay.begin_backtrack_preview(); + assert_eq!(overlay.selected_user_history_position(), Some(1)); + overlay.select_prev_user(); + assert_eq!(overlay.selected_user_history_position(), Some(0)); + } + + #[test] + fn transcript_overlay_user_selection_renders_full_row_highlight() { + let mut overlay = TranscriptOverlay::new( + vec![TranscriptOverlayCell { + lines: vec![ + Line::from("▌"), + Line::from("▌ selected message"), + Line::from("▌"), + ], + is_stream_continuation: false, + user_message: Some(crate::chatwidget::UserMessage::from("selected message")), + is_selected_user: false, + }], + 40, + ); + overlay.begin_backtrack_preview(); + + let area = Rect::new(0, 0, 40, 8); + let mut buf = Buffer::empty(area); + overlay.view.render(area, &mut buf); + + let target_row = (0..area.height) + .find(|row| { + (0..area.width) + .map(|col| buf[(col, *row)].symbol()) + .collect::() + .contains("selected message") + }) + .expect("selected message row"); + let bg0 = buf[(0, target_row)].style().bg; + let bg10 = buf[(10, target_row)].style().bg; + let bg30 = buf[(30, target_row)].style().bg; + assert_eq!(bg0, bg10); + assert_eq!(bg10, bg30); + assert_ne!(bg0, Some(Color::Reset)); + } } diff --git a/crates/tui/src/status_indicator_widget.rs b/crates/tui/src/status_indicator_widget.rs index 1101cbf..921bb2b 100644 --- a/crates/tui/src/status_indicator_widget.rs +++ b/crates/tui/src/status_indicator_widget.rs @@ -226,10 +226,8 @@ impl StatusIndicatorWidget { impl Renderable for StatusIndicatorWidget { fn desired_height(&self, width: u16) -> u16 { - let base = 1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0); - // Keep a minimum height so the status row can still breathe when details - // are shown, but leave the blank separator to the parent layout. - base.max(2) + let details_height = u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0); + 1 + details_height } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -295,7 +293,7 @@ mod tests { use pretty_assertions::assert_eq; #[test] - fn status_indicator_reserves_top_padding_row() { + fn status_indicator_renders_single_header_row_without_details() { let (app_event_tx, _app_event_rx) = tokio::sync::mpsc::unbounded_channel(); let widget = StatusIndicatorWidget::new( AppEventSender::new(app_event_tx), @@ -303,9 +301,9 @@ mod tests { false, ); - assert_eq!(widget.desired_height(80), 2); + assert_eq!(widget.desired_height(80), 1); - let area = Rect::new(0, 0, 20, 2); + let area = Rect::new(0, 0, 20, 1); let mut buf = Buffer::empty(area); widget.render(area, &mut buf); diff --git a/crates/tui/src/tool_result_cell.rs b/crates/tui/src/tool_result_cell.rs index 4592573..cdfe2e5 100644 --- a/crates/tui/src/tool_result_cell.rs +++ b/crates/tui/src/tool_result_cell.rs @@ -11,6 +11,9 @@ 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; + #[derive(Debug)] pub(crate) struct ToolResultCell { title_line: Option>, @@ -42,7 +45,12 @@ impl ToolResultCell { fn inline_lines(&self, width: u16) -> Vec> { let mut lines = self.title_line.iter().cloned().collect::>(); - let mut preview_lines = truncated_tool_output_preview(&self.output, width, 2); + let mut preview_lines = truncated_tool_output_preview( + &self.output, + width, + INLINE_OUTPUT_PREVIEW_ROWS, + INLINE_OUTPUT_PREVIEW_LINE_LIMIT, + ); for line in &mut preview_lines { line.spans = line .spans @@ -134,7 +142,13 @@ mod tests { let inline = plain(cell.display_lines(80)).join("\n"); let transcript = plain(cell.transcript_lines(80)).join("\n"); - assert!(!inline.contains("line 5")); + 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!(transcript.contains("line 5")); assert!(transcript.contains("line 8")); } diff --git a/crates/tui/src/tui.rs b/crates/tui/src/tui.rs index 4686dab..b9877f0 100644 --- a/crates/tui/src/tui.rs +++ b/crates/tui/src/tui.rs @@ -83,8 +83,6 @@ use crate::tui::event_stream::TuiEventStream; use crate::tui::frame_requester::FrameRequester; #[cfg(unix)] use crate::tui::job_control::SuspendContext; -use devo_utils::terminal_detection::Multiplexer; -use devo_utils::terminal_detection::TerminalName; #[cfg(unix)] mod job_control; @@ -100,26 +98,10 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; -fn keyboard_enhancement_supported() -> bool { - if !supports_keyboard_enhancement().unwrap_or(false) { - return false; - } - - let info = devo_utils::terminal_detection::terminal_info(); - if matches!( - info.multiplexer, - Some(Multiplexer::Tmux { .. } | Multiplexer::Zellij {}) - ) { - return false; - } - - matches!( - info.name, - TerminalName::Kitty - | TerminalName::WezTerm - | TerminalName::Alacritty - | TerminalName::Ghostty - ) +fn keyboard_enhancement_flags() -> KeyboardEnhancementFlags { + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS } pub fn set_modes() -> Result<()> { @@ -132,16 +114,10 @@ pub fn set_modes() -> Result<()> { // Some terminals (notably legacy Windows consoles) do not support // keyboard enhancement flags. Attempt to enable them, but continue // gracefully if unsupported. - if keyboard_enhancement_supported() { - let _ = execute!( - stdout(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - ) - ); - } + let _ = execute!( + stdout(), + PushKeyboardEnhancementFlags(keyboard_enhancement_flags()) + ); let _ = execute!(stdout(), EnableFocusChange); Ok(()) @@ -212,9 +188,7 @@ fn restore_common(should_disable_raw_mode: bool) -> Result<()> { // drop" approach, which was sending extra terminal control sequences and could // shift the shell prompt in Terminal.app. // Pop may fail on platforms that didn't support the push; ignore errors. - if keyboard_enhancement_supported() { - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); - } + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange); if should_disable_raw_mode { @@ -760,16 +734,27 @@ impl Tui { #[cfg(test)] mod tests { + use crossterm::event::KeyboardEnhancementFlags; use pretty_assertions::assert_eq; use ratatui::layout::Rect; use ratatui::text::Line; use super::Tui; + use super::keyboard_enhancement_flags; use crate::custom_terminal::Terminal as CustomTerminal; use crate::history_cell::ScrollbackLine; use crate::insert_history::insert_history_lines; use crate::test_backend::VT100Backend; + #[test] + fn keyboard_enhancement_flags_match_codex_rs() { + let flags = keyboard_enhancement_flags(); + + assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)); + assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)); + assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS)); + } + #[test] fn reset_inline_session_ui_clears_pending_history_and_visible_transcript() { let width: u16 = 24; diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index dd05805..ab574a9 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -18,6 +18,8 @@ use devo_core::SessionId; use devo_core::TurnId; use devo_core::TurnStatus; use devo_core::test_model_connection; +use devo_protocol::SessionHistoryMetadata; +use devo_protocol::SessionPlanStepStatus; use devo_provider::ModelProviderSDK; use devo_provider::anthropic::AnthropicProvider; use devo_provider::openai::OpenAIProvider; @@ -51,6 +53,8 @@ use devo_server::TurnStartParams; use devo_server::TurnSteerParams; use crate::app_command::InputHistoryDirection; +use crate::events::PlanStep; +use crate::events::PlanStepStatus; use crate::events::SessionListEntry; use crate::events::TextItemKind; use crate::events::TranscriptItem; @@ -67,6 +71,8 @@ struct EnsureSessionOutcome { /// Immutable runtime configuration used to construct the background server client worker. pub(crate) struct QueryWorkerConfig { + /// Optional pre-existing session to resume immediately on startup. + pub(crate) initial_session_id: Option, /// Model identifier used for new turns. pub(crate) model: String, /// Working directory used for the server session. @@ -422,6 +428,61 @@ async fn run_worker_inner( let mut latest_completed_agent_message: Option = None; let mut input_history_cursor: Option = None; + if let Some(initial_session_id) = config.initial_session_id { + match client + .session_resume(SessionResumeParams { + session_id: initial_session_id, + }) + .await + { + Ok(resumed) => { + active_turn_id = None; + session_id = Some(initial_session_id); + session_cwd = resumed.session.cwd.clone(); + let _ = event_tx.send(WorkerEvent::SessionSwitched { + session_id: initial_session_id.to_string(), + cwd: resumed.session.cwd, + title: resumed.session.title, + model: resumed.session.model.clone(), + thinking: resumed.session.thinking.clone(), + reasoning_effort: resumed.session.reasoning_effort, + total_input_tokens: resumed.session.total_input_tokens, + total_output_tokens: resumed.session.total_output_tokens, + total_cache_read_tokens: resumed.session.total_cache_read_tokens, + last_query_total_tokens: resumed.session.last_query_total_tokens, + last_query_input_tokens: resumed + .latest_turn + .as_ref() + .and_then(|turn| turn.usage.as_ref()) + .map(|usage| usage.input_tokens as usize) + .unwrap_or(0), + prompt_token_estimate: resumed.session.prompt_token_estimate, + history_items: project_history_items(&resumed.history_items), + rich_history_items: resumed.history_items.clone(), + loaded_item_count: resumed.loaded_item_count, + pending_texts: resumed.pending_texts, + }); + model = resumed.session.model.clone().unwrap_or(model); + thinking_selection = resumed.session.thinking.clone(); + total_input_tokens = resumed.session.total_input_tokens; + total_output_tokens = resumed.session.total_output_tokens; + total_cache_read_tokens = resumed.session.total_cache_read_tokens; + last_query_total_tokens = resumed.session.last_query_total_tokens; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: format!("failed to resume session: {error}"), + turn_count, + total_input_tokens, + total_output_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + loop { tokio::select! { maybe_command = command_rx.recv() => { @@ -446,6 +507,9 @@ async fn run_worker_inner( .or(thinking_selection); let active_session_id = session_start.session_id; if session_start.created { + let _ = event_tx.send(WorkerEvent::SessionActivated { + session_id: active_session_id, + }); apply_session_permissions( &mut client, active_session_id, @@ -743,6 +807,7 @@ async fn run_worker_inner( .unwrap_or(0), prompt_token_estimate: result.session.prompt_token_estimate, history_items: project_history_items(&result.history_items), + rich_history_items: result.history_items.clone(), loaded_item_count: result.loaded_item_count, pending_texts: result.pending_texts, }); @@ -857,6 +922,7 @@ async fn run_worker_inner( .unwrap_or(0), prompt_token_estimate: result.session.prompt_token_estimate, history_items: project_history_items(&result.history_items), + rich_history_items: result.history_items.clone(), loaded_item_count: result.loaded_item_count, pending_texts: result.pending_texts, }); @@ -936,6 +1002,7 @@ async fn run_worker_inner( .unwrap_or(0), prompt_token_estimate: resumed.session.prompt_token_estimate, history_items: project_history_items(&resumed.history_items), + rich_history_items: resumed.history_items.clone(), loaded_item_count: resumed.loaded_item_count, pending_texts: resumed.pending_texts, }); @@ -1188,6 +1255,7 @@ async fn run_worker_inner( let _ = event_tx.send(WorkerEvent::ToolCall { tool_use_id: payload.tool_call_id, summary: payload.command, + parsed_commands: Some(payload.command_actions), }); } } @@ -1199,8 +1267,9 @@ async fn run_worker_inner( { let summary = summarize_tool_call(&payload); let _ = event_tx.send(WorkerEvent::ToolCall { - tool_use_id: payload.tool_call_id, + tool_use_id: payload.tool_call_id.clone(), summary, + parsed_commands: Some(payload.command_actions), }); } } @@ -1381,6 +1450,26 @@ async fn run_worker_inner( }); } } + "turn/plan/updated" => { + if let ServerEvent::TurnPlanUpdated(payload) = event { + let steps = payload + .plan + .into_iter() + .filter_map(|step| { + Some(PlanStep { + text: step.step, + status: parse_plan_step_status(&step.status)?, + }) + }) + .collect::>(); + let _ = event_tx.send(WorkerEvent::PlanUpdated { + explanation: payload + .explanation + .filter(|text| !text.trim().is_empty()), + steps, + }); + } + } "inputQueue/updated" => { if let ServerEvent::InputQueueUpdated(payload) = event { let _ = event_tx.send(WorkerEvent::InputQueueUpdated { @@ -1604,6 +1693,21 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe // item/completed since it arrives later (after the tool actually finishes). let _ = payload; } + ItemEnvelope { + item_kind: ItemKind::FileChange, + payload, + .. + } => { + let Ok(payload) = serde_json::from_value::(payload) + else { + return; + }; + let changes = payload + .changes + .into_iter() + .collect::>(); + let _ = event_tx.send(WorkerEvent::PatchApplied { changes }); + } ItemEnvelope { item_kind: ItemKind::ToolResult, payload, @@ -1612,6 +1716,16 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe let Ok(payload) = serde_json::from_value::(payload) else { return; }; + // Compatibility fallback until all live file changes come through ItemKind::FileChange. + if let Some(patch_event) = patch_event_from_tool_result(&payload) { + let _ = event_tx.send(patch_event); + return; + } + // Compatibility fallback until all live plan updates come through turn/plan/updated. + if let Some(plan_event) = plan_event_from_tool_result(&payload) { + let _ = event_tx.send(plan_event); + return; + } let title = if payload.summary.is_empty() { summarize_tool_result_title(payload.tool_name.as_deref(), payload.is_error) } else { @@ -1712,6 +1826,43 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { while index < items.len() { let item = &items[index]; + if let Some(metadata) = &item.metadata { + match metadata { + SessionHistoryMetadata::PlanUpdate { explanation, steps } => { + transcript.push(TranscriptItem::new( + TranscriptItemKind::System, + explanation.clone().unwrap_or_default(), + steps + .iter() + .map(|step| { + let status = match step.status { + SessionPlanStepStatus::Pending => "pending", + SessionPlanStepStatus::InProgress => "in_progress", + SessionPlanStepStatus::Completed => "completed", + SessionPlanStepStatus::Cancelled => "cancelled", + }; + format!("{status}: {}", step.text) + }) + .collect::>() + .join("\n"), + )); + index += 1; + continue; + } + SessionHistoryMetadata::Explored { actions } => { + let title = item.title.clone(); + let body = actions + .iter() + .map(|action| format!("{action:?}")) + .collect::>() + .join("\n"); + transcript.push(TranscriptItem::restored_tool_result(title, body)); + index += 1; + continue; + } + SessionHistoryMetadata::Edited { .. } => {} + } + } if item.kind == SessionHistoryItemKind::ToolCall && let Some(tool_call_id) = item.tool_call_id.as_deref() && let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() @@ -1895,7 +2046,6 @@ fn summarize_tool_input(tool_name: &str, input: &serde_json::Value) -> String { .and_then(serde_json::Value::as_str) .map(|s| s.to_string()), "question" => None, - "todowrite" => None, "skill" => input .get("name") .and_then(serde_json::Value::as_str) @@ -1937,6 +2087,94 @@ fn render_json_value_text(value: &serde_json::Value) -> String { } } +// Legacy compatibility fallback for sessions/items persisted before server-side +// TurnPlanUpdated became the primary live source. +fn plan_event_from_tool_result(payload: &ToolResultPayload) -> Option { + let tool_name = payload.tool_name.as_deref()?; + match tool_name { + "update_plan" => { + let plan = payload.content.get("plan")?.as_array()?; + let explanation = payload + .content + .get("explanation") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .filter(|text| !text.trim().is_empty()); + let steps = plan + .iter() + .filter_map(|item| { + let text = item.get("step")?.as_str()?.to_string(); + let status = parse_plan_step_status( + item.get("status").and_then(serde_json::Value::as_str)?, + )?; + Some(PlanStep { text, status }) + }) + .collect::>(); + Some(WorkerEvent::PlanUpdated { explanation, steps }) + } + _ => None, + } +} + +// Legacy compatibility fallback for sessions/items persisted before server-side +// FileChange became the primary live source. +fn patch_event_from_tool_result(payload: &ToolResultPayload) -> Option { + if !matches!(payload.tool_name.as_deref()?, "apply_patch" | "write") { + return None; + } + let files = payload.content.get("files")?.as_array()?; + let mut changes = std::collections::HashMap::new(); + for file in files { + let path = std::path::PathBuf::from(file.get("path")?.as_str()?); + let kind = file.get("kind").and_then(serde_json::Value::as_str)?; + let additions = file + .get("additions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let deletions = file + .get("deletions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let change = match kind { + "add" => devo_protocol::protocol::FileChange::Add { + content: "\n".repeat(additions as usize), + }, + "delete" => devo_protocol::protocol::FileChange::Delete { + content: "\n".repeat(deletions as usize), + }, + "update" | "move" => devo_protocol::protocol::FileChange::Update { + unified_diff: file + .get("diff") + .or_else(|| file.get("patch")) + .or_else(|| payload.content.get("diff")) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + move_path: file + .get("move_path") + .and_then(serde_json::Value::as_str) + .map(std::path::PathBuf::from), + }, + _ => continue, + }; + changes.insert(path, change); + } + if changes.is_empty() { + return None; + } + Some(WorkerEvent::PatchApplied { changes }) +} + +fn parse_plan_step_status(status: &str) -> Option { + match status { + "pending" => Some(PlanStepStatus::Pending), + "in_progress" => Some(PlanStepStatus::InProgress), + "completed" => Some(PlanStepStatus::Completed), + "cancelled" => Some(PlanStepStatus::Cancelled), + _ => None, + } +} + fn truncate_tool_output(content: &str) -> String { const MAX_LINES: usize = 8; const MAX_CHARS: usize = 1200; @@ -2084,9 +2322,11 @@ fn map_join_error(error: JoinError) -> anyhow::Error { mod tests { use chrono::Utc; use pretty_assertions::assert_eq; + use std::path::PathBuf; use devo_core::SessionId; use devo_core::SessionTitleState; + use devo_server::CommandExecutionPayload; use devo_server::SessionMetadata; use devo_server::SessionRuntimeStatus; @@ -2095,11 +2335,15 @@ mod tests { use super::project_history_items; use super::summarize_tool_call; use super::truncate_tool_output; + use crate::events::PlanStep; + use crate::events::PlanStepStatus; use crate::events::SessionListEntry; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; use crate::events::WorkerEvent; use devo_core::ItemId; + use devo_protocol::SessionHistoryMetadata; + use devo_protocol::SessionPlanStepStatus; use devo_server::ItemEnvelope; use devo_server::ItemEventPayload; use devo_server::ItemKind; @@ -2116,6 +2360,7 @@ mod tests { parameters: serde_json::json!({ "command": "Get-Date -Format \"yyyy-MM-dd\"" }), + command_actions: Vec::new(), }; assert_eq!( @@ -2221,6 +2466,281 @@ mod tests { ); } + #[test] + fn completed_update_plan_tool_result_emits_plan_updated() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("update_plan".to_string()), + content: serde_json::json!({ + "explanation": "Working through the task", + "plan": [ + { "step": "Inspect code", "status": "completed" }, + { "step": "Patch bug", "status": "in_progress" } + ] + }), + display_content: None, + is_error: false, + summary: "update_plan".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + assert_eq!( + event_rx.try_recv().expect("worker event"), + WorkerEvent::PlanUpdated { + explanation: Some("Working through the task".to_string()), + steps: vec![ + PlanStep { + text: "Inspect code".to_string(), + status: PlanStepStatus::Completed, + }, + PlanStep { + text: "Patch bug".to_string(), + status: PlanStepStatus::InProgress, + }, + ], + } + ); + } + + #[test] + fn completed_apply_patch_tool_result_emits_patch_applied() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("apply_patch".to_string()), + content: serde_json::json!({ + "diff": "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", + "files": [ + { + "path": "foo.txt", + "kind": "update", + "additions": 1, + "deletions": 1 + } + ] + }), + display_content: None, + is_error: false, + summary: "apply_patch".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") + else { + panic!("expected patch applied event"); + }; + assert!(changes.contains_key(&std::path::PathBuf::from("foo.txt"))); + } + + #[test] + fn completed_write_tool_result_emits_patch_applied() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("write".to_string()), + content: 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", + "additions": 1, + "deletions": 1 + } + ] + }), + display_content: None, + is_error: false, + summary: "write foo.txt".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") + else { + panic!("expected patch applied event"); + }; + assert!(changes.contains_key(&std::path::PathBuf::from("foo.txt"))); + } + + #[test] + fn completed_apply_patch_tool_result_with_real_metadata_shape_emits_patch_applied() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("apply_patch".to_string()), + content: serde_json::json!({ + "diff": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", + "files": [ + { + "path": "update.txt", + "filePath": "/tmp/update.txt", + "relativePath": "update.txt", + "kind": "update", + "type": "update", + "diff": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", + "patch": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", + "additions": 1, + "deletions": 1 + } + ] + }), + display_content: None, + is_error: false, + summary: "apply_patch".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") + else { + panic!("expected patch applied event"); + }; + assert!(changes.contains_key(&std::path::PathBuf::from("update.txt"))); + } + + #[test] + fn completed_apply_patch_prefers_file_local_diff_over_top_level_diff() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("apply_patch".to_string()), + content: serde_json::json!({ + "diff": "BROKEN TOP LEVEL DIFF", + "files": [ + { + "path": "update.txt", + "kind": "update", + "diff": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", + "additions": 1, + "deletions": 1 + } + ] + }), + display_content: None, + is_error: false, + summary: "apply_patch".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") + else { + panic!("expected patch applied event"); + }; + let devo_protocol::protocol::FileChange::Update { unified_diff, .. } = changes + .get(&std::path::PathBuf::from("update.txt")) + .expect("update change") + else { + panic!("expected update change"); + }; + assert!(unified_diff.contains("--- a/update.txt")); + assert!(!unified_diff.contains("BROKEN TOP LEVEL DIFF")); + } + + #[test] + fn command_execution_started_event_uses_server_command_actions() { + let payload = CommandExecutionPayload { + tool_call_id: "call-1".to_string(), + tool_name: "read".to_string(), + command: "read crates/tui/src/chatwidget.rs".to_string(), + source: devo_protocol::protocol::ExecCommandSource::Agent, + command_actions: vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read crates/tui/src/chatwidget.rs".to_string(), + name: "chatwidget.rs".to_string(), + path: PathBuf::from("crates/tui/src/chatwidget.rs"), + }], + output: None, + is_error: false, + }; + + assert_eq!( + WorkerEvent::ToolCall { + tool_use_id: payload.tool_call_id.clone(), + summary: payload.command.clone(), + parsed_commands: Some(payload.command_actions.clone()), + }, + WorkerEvent::ToolCall { + tool_use_id: payload.tool_call_id, + summary: payload.command, + parsed_commands: Some(payload.command_actions), + } + ); + } + #[test] fn session_list_entries_keep_title_before_identifier() { let active_session_id = SessionId::new(); @@ -2307,6 +2827,7 @@ mod tests { kind: SessionHistoryItemKind::ToolCall, title: "Ran powershell -Command \"Get-Date\"".to_string(), body: String::new(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2314,6 +2835,7 @@ mod tests { kind: SessionHistoryItemKind::ToolResult, title: "Tool output".to_string(), body: "2026-04-09".to_string(), + metadata: None, duration_ms: None, }, ]; @@ -2335,6 +2857,7 @@ mod tests { kind: SessionHistoryItemKind::ToolCall, title: "Ran read a".to_string(), body: String::new(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2342,6 +2865,7 @@ mod tests { kind: SessionHistoryItemKind::ToolCall, title: "Ran read b".to_string(), body: String::new(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2349,6 +2873,7 @@ mod tests { kind: SessionHistoryItemKind::ToolResult, title: "Tool output".to_string(), body: "B".to_string(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2356,6 +2881,7 @@ mod tests { kind: SessionHistoryItemKind::ToolResult, title: "Tool output".to_string(), body: "A".to_string(), + metadata: None, duration_ms: None, }, ]; @@ -2369,6 +2895,30 @@ mod tests { ); } + #[test] + fn project_history_understands_plan_metadata() { + let items = vec![SessionHistoryItem { + tool_call_id: None, + kind: SessionHistoryItemKind::Assistant, + title: String::new(), + body: r#"{"explanation":"Do work","plan":[{"step":"Inspect","status":"completed"}]}"# + .to_string(), + metadata: Some(SessionHistoryMetadata::PlanUpdate { + explanation: Some("Do work".to_string()), + steps: vec![devo_protocol::SessionPlanStep { + text: "Inspect".to_string(), + status: SessionPlanStepStatus::Completed, + }], + }), + duration_ms: None, + }]; + + let projected = project_history_items(&items); + assert_eq!(projected.len(), 1); + assert_eq!(projected[0].kind, TranscriptItemKind::System); + assert!(projected[0].body.contains("completed: Inspect")); + } + #[test] fn project_history_restores_command_execution_items() { let items = vec![SessionHistoryItem { @@ -2376,6 +2926,7 @@ mod tests { kind: SessionHistoryItemKind::CommandExecution, title: "cargo test".to_string(), body: "ok".to_string(), + metadata: None, duration_ms: None, }]; @@ -2392,6 +2943,7 @@ mod tests { kind: SessionHistoryItemKind::Reasoning, title: String::new(), body: "thinking aloud".to_string(), + metadata: None, duration_ms: None, }]; diff --git a/docs/design-overview.md b/docs/design-overview.md index 7f65d3c..49ffcb8 100644 --- a/docs/design-overview.md +++ b/docs/design-overview.md @@ -208,7 +208,6 @@ The first milestone should include at least: - web search (`websearch`) - sub-agent task dispatch (`task`) - interactive user questions (`question`) -- structured task list management (`todo_write`) Tool execution must follow a stable lifecycle: diff --git a/docs/spec-tools.md b/docs/spec-tools.md index a8c2ce3..2c655a3 100644 --- a/docs/spec-tools.md +++ b/docs/spec-tools.md @@ -229,7 +229,6 @@ The current built-in tools in `devo-tools` include: - `websearch` — web search - `task` — sub-agent task dispatch - `question` — interactive user questions -- `todo_write` — structured task list management - `skill` — skill loading - `plan` — plan mode support - `lsp` — language server protocol integration