From b90da3eeadd258d3e09ef12136877fec13961948 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 16 May 2026 15:10:28 +0800 Subject: [PATCH 1/8] chore: commit current workspace changes --- crates/core/src/query.rs | 256 +++++++++++++++++--- crates/tools/src/handlers/websearch.rs | 15 +- crates/tools/src/router.rs | 156 +++++++++++- crates/tui/src/bottom_pane/chat_composer.rs | 16 ++ crates/tui/src/bottom_pane/mod.rs | 44 +++- crates/tui/src/chatwidget.rs | 7 +- crates/tui/src/chatwidget_tests.rs | 148 ++++++++++- crates/tui/src/exec_cell/render.rs | 3 +- crates/tui/src/host.rs | 14 ++ crates/tui/src/tool_result_cell.rs | 18 +- crates/tui/src/tui.rs | 57 ++--- 11 files changed, 651 insertions(+), 83 deletions(-) diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index fbe904e4..a63fcf10 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -808,28 +808,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 = micro_compact(result.content.clone().into_string()); + 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 +861,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 +1001,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 +1069,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 +1328,8 @@ mod tests { struct StreamingMutatingTool; + struct ParallelDelayTool; + #[async_trait] impl ToolHandler for StreamingMutatingTool { fn tool_kind(&self) -> ToolHandlerKind { @@ -1253,6 +1348,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 { @@ -1995,4 +2116,83 @@ 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, + .. + } => { + 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/tools/src/handlers/websearch.rs b/crates/tools/src/handlers/websearch.rs index 8711f7f4..4559127a 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/router.rs b/crates/tools/src/router.rs index a52b30f7..f4d5e3fd 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/tui/src/bottom_pane/chat_composer.rs b/crates/tui/src/bottom_pane/chat_composer.rs index 2694b2d0..9facf2b7 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 c9b136c0..7d8bce2c 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -4,6 +4,8 @@ use std::time::Duration; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use crossterm::event::ModifierKeyCode; use devo_protocol::user_input::TextElement; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -252,6 +254,7 @@ pub(crate) struct BottomPane { animations_enabled: bool, has_input_focus: bool, allow_empty_submit: bool, + held_enter_modifiers: KeyModifiers, external_history_active: bool, external_history_draft: Option, accent_color: Color, @@ -298,6 +301,7 @@ impl BottomPane { animations_enabled, has_input_focus, allow_empty_submit: false, + held_enter_modifiers: KeyModifiers::NONE, external_history_active: false, external_history_draft: None, accent_color: Color::Cyan, @@ -310,7 +314,7 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> InputResult { + pub(crate) fn handle_key_event(&mut self, mut key: KeyEvent) -> InputResult { // Route to onboarding first — it takes priority over views and composer. if let Some(handle) = self.onboarding.as_mut() { handle.handle_key_event(key); @@ -322,6 +326,13 @@ impl BottomPane { return self.handle_view_key_event(key); } + if self.track_enter_modifier_key(key) { + return InputResult::None; + } + if key.code == KeyCode::Enter && key.modifiers == KeyModifiers::NONE { + key.modifiers = self.held_enter_modifiers; + } + if key.code == KeyCode::Esc && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running @@ -348,6 +359,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() { @@ -370,6 +382,36 @@ impl BottomPane { self.map_composer_input_result(input_result) } + fn track_enter_modifier_key(&mut self, key: KeyEvent) -> bool { + let KeyCode::Modifier(modifier) = key.code else { + return false; + }; + let tracked_modifier = match modifier { + ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift => KeyModifiers::SHIFT, + ModifierKeyCode::LeftControl | ModifierKeyCode::RightControl => KeyModifiers::CONTROL, + ModifierKeyCode::LeftAlt + | ModifierKeyCode::LeftSuper + | ModifierKeyCode::LeftHyper + | ModifierKeyCode::LeftMeta + | ModifierKeyCode::RightAlt + | ModifierKeyCode::RightSuper + | ModifierKeyCode::RightHyper + | ModifierKeyCode::RightMeta + | ModifierKeyCode::IsoLevel3Shift + | ModifierKeyCode::IsoLevel5Shift => return false, + }; + + match key.kind { + KeyEventKind::Press | KeyEventKind::Repeat => { + self.held_enter_modifiers.insert(tracked_modifier); + } + KeyEventKind::Release => { + self.held_enter_modifiers.remove(tracked_modifier); + } + } + true + } + pub fn handle_paste(&mut self, pasted: String) { if !self.view_stack.is_empty() { let (needs_redraw, view_complete) = { diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 28252466..d24e9cba 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -859,6 +859,10 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key: KeyEvent) { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + if matches!(key.kind, KeyEventKind::Release) && matches!(key.code, KeyCode::Modifier(_)) + { + let _ = self.bottom_pane.handle_key_event(key); + } return; } if self.resume_browser.is_some() { @@ -2473,7 +2477,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 diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 9b0ed47c..ec2b4886 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -4,6 +4,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; +use crossterm::event::ModifierKeyCode; use devo_protocol::ApprovalDecisionValue; use devo_protocol::ApprovalScopeValue; use devo_protocol::InputItem; @@ -588,6 +589,139 @@ fn typed_character_submits_after_paste_burst_flush() { ); } +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 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 text = submitted_text_after_modified_enter(KeyModifiers::SHIFT, model, cwd); + + assert_eq!(text, "hello\nworld"); +} + +#[test] +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 text = submitted_text_after_modified_enter(KeyModifiers::CONTROL, model, cwd); + + assert_eq!(text, "hello\nworld"); +} + +fn submitted_text_after_held_modifier_enter( + modifier_key: ModifierKeyCode, + held_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_with_kind( + KeyCode::Modifier(modifier_key), + held_modifier, + KeyEventKind::Press, + )); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_no_command_emitted(&mut app_event_rx); + widget.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Modifier(modifier_key), + held_modifier, + KeyEventKind::Release, + )); + 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 held_shift_then_plain_enter_inserts_newline_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 text = submitted_text_after_held_modifier_enter( + ModifierKeyCode::LeftShift, + KeyModifiers::SHIFT, + model, + cwd, + ); + + assert_eq!(text, "hello\nworld"); +} + +#[test] +fn held_ctrl_then_plain_enter_inserts_newline_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 text = submitted_text_after_held_modifier_enter( + ModifierKeyCode::LeftControl, + KeyModifiers::CONTROL, + model, + cwd, + ); + + assert_eq!(text, "hello\nworld"); +} + #[test] fn key_release_does_not_duplicate_text_input() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -2373,7 +2507,19 @@ fn transcript_overlay_lines_include_full_completed_tool_output() { .join("\n"); assert!( - !inline.contains("line 5"), + 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!( diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index c422e0d3..e66b0a40 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, diff --git a/crates/tui/src/host.rs b/crates/tui/src/host.rs index efcec3ec..07275871 100644 --- a/crates/tui/src/host.rs +++ b/crates/tui/src/host.rs @@ -358,6 +358,20 @@ fn handle_tui_event( })?; } TuiEvent::Key(key) => { + 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) { diff --git a/crates/tui/src/tool_result_cell.rs b/crates/tui/src/tool_result_cell.rs index 45925739..cdfe2e50 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 4686dabb..535d0c08 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,11 @@ 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 + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES } pub fn set_modes() -> Result<()> { @@ -132,16 +115,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 +189,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 +735,28 @@ 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_request_all_keys_as_escape_codes() { + 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)); + assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES)); + } + #[test] fn reset_inline_session_ui_clears_pending_history_and_visible_transcript() { let width: u16 = 24; From 7211e12f3f676e5065346752c45e6d7fc92e0a7f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 16 May 2026 15:29:29 +0800 Subject: [PATCH 2/8] tui: align modified enter handling with codex-rs --- crates/tui/src/bottom_pane/mod.rs | 42 +---------------- crates/tui/src/chatwidget.rs | 4 -- crates/tui/src/chatwidget_tests.rs | 75 ------------------------------ crates/tui/src/tui.rs | 4 +- 4 files changed, 2 insertions(+), 123 deletions(-) diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index 7d8bce2c..021c3232 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -5,7 +5,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use crossterm::event::ModifierKeyCode; use devo_protocol::user_input::TextElement; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -254,7 +253,6 @@ pub(crate) struct BottomPane { animations_enabled: bool, has_input_focus: bool, allow_empty_submit: bool, - held_enter_modifiers: KeyModifiers, external_history_active: bool, external_history_draft: Option, accent_color: Color, @@ -301,7 +299,6 @@ impl BottomPane { animations_enabled, has_input_focus, allow_empty_submit: false, - held_enter_modifiers: KeyModifiers::NONE, external_history_active: false, external_history_draft: None, accent_color: Color::Cyan, @@ -314,7 +311,7 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn handle_key_event(&mut self, mut key: KeyEvent) -> InputResult { + pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> InputResult { // Route to onboarding first — it takes priority over views and composer. if let Some(handle) = self.onboarding.as_mut() { handle.handle_key_event(key); @@ -326,13 +323,6 @@ impl BottomPane { return self.handle_view_key_event(key); } - if self.track_enter_modifier_key(key) { - return InputResult::None; - } - if key.code == KeyCode::Enter && key.modifiers == KeyModifiers::NONE { - key.modifiers = self.held_enter_modifiers; - } - if key.code == KeyCode::Esc && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running @@ -382,36 +372,6 @@ impl BottomPane { self.map_composer_input_result(input_result) } - fn track_enter_modifier_key(&mut self, key: KeyEvent) -> bool { - let KeyCode::Modifier(modifier) = key.code else { - return false; - }; - let tracked_modifier = match modifier { - ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift => KeyModifiers::SHIFT, - ModifierKeyCode::LeftControl | ModifierKeyCode::RightControl => KeyModifiers::CONTROL, - ModifierKeyCode::LeftAlt - | ModifierKeyCode::LeftSuper - | ModifierKeyCode::LeftHyper - | ModifierKeyCode::LeftMeta - | ModifierKeyCode::RightAlt - | ModifierKeyCode::RightSuper - | ModifierKeyCode::RightHyper - | ModifierKeyCode::RightMeta - | ModifierKeyCode::IsoLevel3Shift - | ModifierKeyCode::IsoLevel5Shift => return false, - }; - - match key.kind { - KeyEventKind::Press | KeyEventKind::Repeat => { - self.held_enter_modifiers.insert(tracked_modifier); - } - KeyEventKind::Release => { - self.held_enter_modifiers.remove(tracked_modifier); - } - } - true - } - pub fn handle_paste(&mut self, pasted: String) { if !self.view_stack.is_empty() { let (needs_redraw, view_complete) = { diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index d24e9cba..6dbe85a3 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -859,10 +859,6 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key: KeyEvent) { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { - if matches!(key.kind, KeyEventKind::Release) && matches!(key.code, KeyCode::Modifier(_)) - { - let _ = self.bottom_pane.handle_key_event(key); - } return; } if self.resume_browser.is_some() { diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index ec2b4886..57da06d0 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -4,7 +4,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use crossterm::event::ModifierKeyCode; use devo_protocol::ApprovalDecisionValue; use devo_protocol::ApprovalScopeValue; use devo_protocol::InputItem; @@ -648,80 +647,6 @@ fn ctrl_enter_inserts_newline_in_composer_without_submitting() { assert_eq!(text, "hello\nworld"); } -fn submitted_text_after_held_modifier_enter( - modifier_key: ModifierKeyCode, - held_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_with_kind( - KeyCode::Modifier(modifier_key), - held_modifier, - KeyEventKind::Press, - )); - widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_no_command_emitted(&mut app_event_rx); - widget.handle_key_event(KeyEvent::new_with_kind( - KeyCode::Modifier(modifier_key), - held_modifier, - KeyEventKind::Release, - )); - 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 held_shift_then_plain_enter_inserts_newline_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 text = submitted_text_after_held_modifier_enter( - ModifierKeyCode::LeftShift, - KeyModifiers::SHIFT, - model, - cwd, - ); - - assert_eq!(text, "hello\nworld"); -} - -#[test] -fn held_ctrl_then_plain_enter_inserts_newline_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 text = submitted_text_after_held_modifier_enter( - ModifierKeyCode::LeftControl, - KeyModifiers::CONTROL, - model, - cwd, - ); - - assert_eq!(text, "hello\nworld"); -} - #[test] fn key_release_does_not_duplicate_text_input() { let cwd = std::env::current_dir().expect("current directory is available"); diff --git a/crates/tui/src/tui.rs b/crates/tui/src/tui.rs index 535d0c08..b9877f08 100644 --- a/crates/tui/src/tui.rs +++ b/crates/tui/src/tui.rs @@ -102,7 +102,6 @@ fn keyboard_enhancement_flags() -> KeyboardEnhancementFlags { KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_EVENT_TYPES | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES } pub fn set_modes() -> Result<()> { @@ -748,13 +747,12 @@ mod tests { use crate::test_backend::VT100Backend; #[test] - fn keyboard_enhancement_flags_request_all_keys_as_escape_codes() { + 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)); - assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES)); } #[test] From e34b4b28f959d2d607a82288718d81537dcc3509 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 16 May 2026 19:08:49 +0800 Subject: [PATCH 3/8] feature: explore; edit; update plan; --- README.md | 1 + crates/core/src/query.rs | 30 +- crates/protocol/src/event.rs | 33 +- crates/protocol/src/session.rs | 45 +- crates/server/src/event.rs | 4 +- crates/server/src/projection.rs | 265 +++++++++-- crates/server/src/runtime/turn_exec.rs | 326 +++++++++++++- crates/tools/src/bash.txt | 4 +- crates/tools/src/handler_kind.rs | 1 - crates/tools/src/handlers/mod.rs | 3 - crates/tools/src/handlers/todo_write.rs | 34 -- crates/tools/src/lib.rs | 3 +- crates/tools/src/registry_plan.rs | 50 --- crates/tools/src/tool_summary.rs | 1 - crates/tui/src/chatwidget.rs | 454 ++++++++++++++++++- crates/tui/src/chatwidget_tests.rs | 572 ++++++++++++++++++++++++ crates/tui/src/diff_render.rs | 2 +- crates/tui/src/events.rs | 34 +- crates/tui/src/exec_cell/mod.rs | 2 + crates/tui/src/exec_cell/render.rs | 6 +- crates/tui/src/host.rs | 2 + crates/tui/src/worker.rs | 343 +++++++++++++- docs/design-overview.md | 1 - docs/spec-tools.md | 1 - 24 files changed, 2051 insertions(+), 166 deletions(-) delete mode 100644 crates/tools/src/handlers/todo_write.rs diff --git a/README.md b/README.md index 15ffda76..8e0c211d 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/core/src/query.rs b/crates/core/src/query.rs index a63fcf10..3b4ce17d 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 // --------------------------------------------------------------------------- @@ -834,7 +846,7 @@ pub async fn query( }); }, move |result| { - let content = micro_compact(result.content.clone().into_string()); + 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()) @@ -2034,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] @@ -2105,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 ) }) @@ -2150,6 +2165,7 @@ mod tests { content, .. } => { + let content = content.into_string(); seen_clone .lock() .expect("lock events") diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 11bf8046..cfe56f99 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -3,6 +3,8 @@ use smol_str::SmolStr; use crate::session::{SessionMetadata, SessionRuntimeStatus}; use crate::turn::TurnMetadata; +use crate::parse_command::ParsedCommand; +use crate::protocol::{ExecCommandSource, FileChange}; use crate::{ItemId, SessionId, TurnId, TurnUsage}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -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 510c07fe..8171abf4 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; @@ -5,9 +6,11 @@ use chrono::Utc; use serde::Deserialize; use serde::Serialize; +use crate::parse_command::ParsedCommand; use crate::ReasoningEffort; use crate::SessionId; use crate::SessionTitleState; +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 3691e506..58a325cf 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 454ec555..d5223086 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -2,10 +2,10 @@ use devo_core::{ CommandExecutionItem, ContentBlock, Message, SessionRecord, TextItem, ToolCallItem, ToolResultItem, TurnItem, TurnRecord, }; +use devo_protocol::{SessionHistoryMetadata, SessionPlanStep, SessionPlanStepStatus}; +use devo_utils::shell_command::parse_command::parse_command; -use crate::session::{ - SessionHistoryItem, SessionHistoryItemKind, SessionMetadata, SessionRuntimeStatus, -}; +use crate::session::{SessionHistoryItem, SessionHistoryItemKind, SessionMetadata, SessionRuntimeStatus}; use crate::turn::TurnMetadata; /// Projects a canonical core session record into the API-visible session summary. @@ -107,7 +107,6 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Some(SessionHistoryItem::new( @@ -116,6 +115,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 +139,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 +196,57 @@ 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); + } + 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 +261,40 @@ 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 }) +} + impl SessionProjector for DefaultProjection { fn project_session( &self, @@ -278,7 +381,8 @@ mod tests { use super::history_item_from_turn_item; use crate::session::SessionHistoryItemKind; - use devo_core::ToolResultItem; + use devo_core::{CommandExecutionItem, TextItem, ToolCallItem, ToolResultItem}; + use devo_protocol::{SessionHistoryMetadata, SessionPlanStepStatus}; use devo_core::TurnItem; #[test] @@ -310,4 +414,91 @@ 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); + } } diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index 7582511d..ffe710b4 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::mpsc; - use super::*; +use crate::{FileChangePayload, TurnPlanStepPayload, TurnPlanUpdatedPayload}; struct PendingToolCall { item_id: ItemId, @@ -59,6 +59,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") +} + +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 +91,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 +221,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 +376,44 @@ 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 +447,151 @@ 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: 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::>(); + + 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 +620,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 +651,26 @@ 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 +1330,54 @@ mod tests { None ); } + + #[test] + fn file_change_tool_detection_matches_apply_patch() { + assert!(is_file_change_tool("apply_patch")); + 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/src/bash.txt b/crates/tools/src/bash.txt index b133105d..df733e51 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 6b6a1016..41017773 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/mod.rs b/crates/tools/src/handlers/mod.rs index 777e677f..122998d6 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 ede06aab..00000000 --- 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/lib.rs b/crates/tools/src/lib.rs index db817fa4..3e5a2501 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 93e29417..b51263b8 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/tool_summary.rs b/crates/tools/src/tool_summary.rs index aab9e990..290f47a6 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/chatwidget.rs b/crates/tui/src/chatwidget.rs index 6dbe85a3..aa6e0556 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -6,6 +6,7 @@ //! through `Model` instead of a TUI-local reasoning enum. use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::PathBuf; use std::time::Instant; @@ -56,10 +57,15 @@ 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::SessionListEntry; +use crate::events::PlanStep; +use crate::events::PlanStepStatus; 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; @@ -68,6 +74,7 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::ScrollbackLine; use crate::markdown::append_markdown; use crate::render::renderable::Renderable; +use crate::render::line_utils::prefix_lines; use crate::slash_command::SlashCommand; use crate::startup_header::STARTUP_HEADER_ANIMATION_INTERVAL; use crate::streaming::chunking::AdaptiveChunkingPolicy; @@ -78,6 +85,8 @@ use crate::theme::ThemeSet; use crate::tool_result_cell::ToolResultCell; use crate::tui::frame_requester::FrameRequester; use devo_utils::ansi_escape::ansi_escape_line; +use devo_utils::shell_command::parse_command::parse_command; +use devo_protocol::{SessionHistoryItem, SessionHistoryMetadata, SessionPlanStepStatus}; /// Common initialization parameters shared by `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { @@ -204,6 +213,7 @@ struct ActiveToolCall { tool_use_id: String, title: String, lines: Vec>, + exec_like: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -350,6 +360,7 @@ 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, pending_approval: Option, @@ -363,6 +374,19 @@ pub(crate) struct ChatWidget { } impl ChatWidget { + 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 } @@ -715,6 +739,172 @@ 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 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 clear_for_session_switch(&mut self) { self.history.clear(); self.next_history_flush_index = 0; @@ -835,6 +1025,7 @@ 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, pending_approval: None, @@ -1302,12 +1493,73 @@ 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 +1577,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); @@ -1354,9 +1618,50 @@ impl ChatWidget { 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); + .unwrap_or(ActiveToolCall { + tool_use_id: tool_use_id.clone(), + title, + lines: Vec::new(), + exec_like: false, + }); + + if resolved_title.exec_like + && 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, + CommandOutput { + exit_code: if is_error { 1 } else { 0 }, + aggregated_output: preview.clone(), + formatted_output: preview.clone(), + }, + std::time::Duration::from_millis(0), + ); + 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; + } + } + + let resolved_title = resolved_title.title; let title_line = (!resolved_title.is_empty()).then(|| Self::ran_tool_line(&resolved_title)); @@ -1376,6 +1681,33 @@ 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 = 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}"), + }; + 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, @@ -1608,6 +1940,7 @@ impl ChatWidget { last_query_input_tokens, prompt_token_estimate, history_items, + rich_history_items, loaded_item_count, pending_texts, } => { @@ -1628,12 +1961,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 +2042,85 @@ 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)); } @@ -2737,6 +3156,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) { @@ -2772,6 +3201,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() } @@ -2849,6 +3286,11 @@ 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) } diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 57da06d0..76914a22 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -24,6 +24,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; @@ -696,6 +698,363 @@ fn key_release_does_not_duplicate_text_input() { ); } +#[test] +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(), + 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::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, + }, + ], + }); + + assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); + + 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 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(), + 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::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![], + }); + + assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); +} + +#[test] +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(), + 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::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![], + }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + blob.contains("Explored") || blob.contains("Exploring"), + "expected explored block after resume, got:\n{blob}" + ); + assert!(blob.contains("Read foo.txt"), "expected read summary, got:\n{blob}"); +} + +#[test] +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(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("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, + 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 blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + blob.contains("Edited foo.txt") || blob.contains("Edited 1 file"), + "expected edited block after resume, got:\n{blob}" + ); +} + +#[test] +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(), + 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![], + 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 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}" + ); + assert!(blob.contains("Read worker.rs"), "expected read entry, got:\n{blob}"); + assert!( + blob.contains("Search command_actions in crates/tui/src/worker.rs"), + "expected search entry, got:\n{blob}" + ); +} + +#[test] +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(), + 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![], + 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 blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!(blob.contains("Ran bash error"), "expected tool-result style title, got:\n{blob}"); + assert!(blob.contains("permission denied"), "expected tool-result body, got:\n{blob}"); +} + +#[test] +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 (mut live_widget, _live_rx) = widget_with_model(model.clone(), PathBuf::from(".")); + let (mut resume_widget, _resume_rx) = widget_with_model(model, PathBuf::from(".")); + + 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"); + + resume_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![], + 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!(live_blob, resume_blob, "live and resume error cells diverged"); +} + #[test] fn startup_header_mascot_animation_advances_on_pre_draw_tick() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -934,6 +1293,7 @@ fn session_switch_restores_header_and_double_blank_line_before_user_input() { "world".to_string(), ), ], + rich_history_items: Vec::new(), loaded_item_count: 2, pending_texts: vec![], }); @@ -1318,6 +1678,7 @@ fn tool_call_start_and_finish_are_both_visible_in_history() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "powershell -NoProfile -Command Get-Date".to_string(), + parsed_commands: None, }); let running = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -1414,6 +1775,7 @@ fn restored_reasoning_text_is_visible_in_transcript() { "", "thinking text", )], + rich_history_items: Vec::new(), loaded_item_count: 1, pending_texts: vec![], }); @@ -1799,6 +2161,7 @@ fn session_switch_updates_session_identity_projection() { 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![], }); @@ -1837,6 +2200,7 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() 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![], }); @@ -1943,6 +2307,7 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( 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![], }); @@ -2409,6 +2774,7 @@ fn transcript_overlay_lines_include_full_completed_tool_output() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "bash".to_string(), + parsed_commands: None, }); widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { tool_use_id: "tool-1".to_string(), @@ -2465,6 +2831,7 @@ fn transcript_overlay_lines_include_running_tool_output_delta() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "bash".to_string(), + parsed_commands: None, }); widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { tool_use_id: "tool-1".to_string(), @@ -2488,3 +2855,208 @@ fn transcript_overlay_lines_include_running_tool_output_delta() { "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 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("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, mut 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: "write src/main.rs".to_string(), + parsed_commands: None, + }); + 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 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" + ); +} + +#[test] +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, PathBuf::from(".")); + + 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::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")); +} diff --git a/crates/tui/src/diff_render.rs b/crates/tui/src/diff_render.rs index be751dd6..b32d8fd2 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 f82b373c..93e43c79 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -1,12 +1,31 @@ use std::time::Instant; +use std::collections::HashMap; +use std::path::PathBuf; use crate::app_command::InputHistoryDirection; use devo_core::ItemId; use devo_core::SessionId; +use devo_protocol::parse_command::ParsedCommand; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningEffort; +use devo_protocol::SessionHistoryItem; +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 { @@ -84,6 +103,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 +126,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 +266,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 393df902..88ef6fb5 100644 --- a/crates/tui/src/exec_cell/mod.rs +++ b/crates/tui/src/exec_cell/mod.rs @@ -3,6 +3,8 @@ mod render; mod spinner; pub(crate) use model::CommandOutput; +pub(crate) use model::ExecCell; +pub(crate) use render::new_active_exec_command; pub(crate) use render::OutputLinesParams; pub(crate) use render::TOOL_CALL_MAX_LINES; pub(crate) use render::output_lines; diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index e66b0a40..c79eb82b 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -307,11 +307,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/host.rs b/crates/tui/src/host.rs index 07275871..c534a490 100644 --- a/crates/tui/src/host.rs +++ b/crates/tui/src/host.rs @@ -561,6 +561,8 @@ fn handle_worker_event( | WorkerEvent::ReasoningCompleted(_) | WorkerEvent::ToolCall { .. } | WorkerEvent::ToolResult { .. } + | WorkerEvent::PatchApplied { .. } + | WorkerEvent::PlanUpdated { .. } | WorkerEvent::SessionsListed { .. } | WorkerEvent::SkillsListed { .. } | WorkerEvent::NewSessionPrepared { .. } diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index dd058059..2268e759 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -49,8 +49,12 @@ use devo_server::TurnEventPayload; use devo_server::TurnInterruptParams; use devo_server::TurnStartParams; use devo_server::TurnSteerParams; +use devo_protocol::SessionHistoryMetadata; +use devo_protocol::SessionPlanStepStatus; 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; @@ -743,6 +747,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 +862,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 +942,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 +1195,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 +1207,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 +1390,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 +1633,17 @@ 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 +1652,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 +1762,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 +1982,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 +2023,93 @@ 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 payload.tool_name.as_deref()? != "apply_patch" { + 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: 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 +2257,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; @@ -2096,10 +2271,14 @@ mod tests { use super::summarize_tool_call; use super::truncate_tool_output; use crate::events::SessionListEntry; + use crate::events::PlanStep; + use crate::events::PlanStepStatus; 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 +2295,7 @@ mod tests { parameters: serde_json::json!({ "command": "Get-Date -Format \"yyyy-MM-dd\"" }), + command_actions: Vec::new(), }; assert_eq!( @@ -2221,6 +2401,133 @@ 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 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 +2614,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 +2622,7 @@ mod tests { kind: SessionHistoryItemKind::ToolResult, title: "Tool output".to_string(), body: "2026-04-09".to_string(), + metadata: None, duration_ms: None, }, ]; @@ -2335,6 +2644,7 @@ mod tests { kind: SessionHistoryItemKind::ToolCall, title: "Ran read a".to_string(), body: String::new(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2342,6 +2652,7 @@ mod tests { kind: SessionHistoryItemKind::ToolCall, title: "Ran read b".to_string(), body: String::new(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2349,6 +2660,7 @@ mod tests { kind: SessionHistoryItemKind::ToolResult, title: "Tool output".to_string(), body: "B".to_string(), + metadata: None, duration_ms: None, }, SessionHistoryItem { @@ -2356,6 +2668,7 @@ mod tests { kind: SessionHistoryItemKind::ToolResult, title: "Tool output".to_string(), body: "A".to_string(), + metadata: None, duration_ms: None, }, ]; @@ -2369,6 +2682,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 +2713,7 @@ mod tests { kind: SessionHistoryItemKind::CommandExecution, title: "cargo test".to_string(), body: "ok".to_string(), + metadata: None, duration_ms: None, }]; @@ -2392,6 +2730,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 7f65d3c5..49ffcb88 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 a8c2ce34..2c655a3e 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 From d0375af774618dc0bc6d0c730302c2c2c85bf18c Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 16 May 2026 19:58:19 +0800 Subject: [PATCH 4/8] tui: fix transcript grouping and prompt rendering --- crates/tui/src/chatwidget.rs | 101 +++--- crates/tui/src/chatwidget_tests.rs | 411 +++++++++++++++++++++- crates/tui/src/exec_cell/render.rs | 3 + crates/tui/src/history_cell.rs | 10 +- crates/tui/src/status_indicator_widget.rs | 12 +- 5 files changed, 480 insertions(+), 57 deletions(-) diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index aa6e0556..78c98e46 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -374,6 +374,20 @@ 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; @@ -1422,6 +1436,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", @@ -1452,6 +1467,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, @@ -1462,6 +1478,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, @@ -1626,32 +1643,47 @@ impl ChatWidget { }); if resolved_title.exec_like - && let Some(cell) = self + { + 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, - CommandOutput { - exit_code: if is_error { 1 } else { 0 }, - aggregated_output: preview.clone(), - formatted_output: preview.clone(), - }, - std::time::Duration::from_millis(0), - ); - 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(); + { + 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 { @@ -1684,16 +1716,7 @@ impl ChatWidget { if Self::should_auto_show_git_diff(&resolved_title, is_error) { 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)); }); } @@ -2263,16 +2286,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)); }); } @@ -3297,6 +3311,9 @@ impl ChatWidget { 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)); diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 76914a22..94b83e1b 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -139,6 +139,17 @@ 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() @@ -151,6 +162,31 @@ fn indices_containing(lines: &[String], needles: &[&str]) -> Vec { .collect() } +#[test] +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(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.submit_text("line one\nline two\nline three".to_string()); + + let transcript = line_texts(widget.transcript_overlay_lines(80)); + let user_lines: Vec = transcript + .into_iter() + .filter(|line| line.starts_with("▌ ")) + .collect(); + + 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 resume_command_opens_loading_browser_immediately() { let model = Model { @@ -1252,7 +1288,7 @@ fn batched_history_inserts_separator_and_trailing_blank_lines() { } #[test] -fn session_switch_restores_header_and_double_blank_line_before_user_input() { +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 { @@ -1322,11 +1358,13 @@ fn session_switch_restores_header_and_double_blank_line_before_user_input() { assert!(!committed_text.contains("session 1 lingering line")); assert!( committed_rows - .windows(3) + .windows(5) .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:?}" + && window[2].trim_end() == "▌" + && window[3].trim().is_empty() + && window[4].contains("world")), + "expected restored spaced user prompt before assistant response: {committed_lines:?}" ); } @@ -2987,6 +3025,371 @@ fn grep_tool_call_renders_as_explored_group_in_viewport() { ); } +#[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)); diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index c79eb82b..5559f4bc 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -251,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 { diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index d0e5cd19..e9ccb6a3 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/status_indicator_widget.rs b/crates/tui/src/status_indicator_widget.rs index 1101cbf0..921bb2bb 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); From f4190cc117fbc117abf860b439d2a758047688ae Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sun, 17 May 2026 13:19:52 +0800 Subject: [PATCH 5/8] tui: fix edited file-change rendering and resume fallback --- Cargo.lock | 1 + crates/server/src/projection.rs | 151 ++++++++++++++++ crates/server/src/runtime/turn_exec.rs | 34 +++- crates/tools/Cargo.toml | 1 + crates/tools/src/apply_patch.rs | 66 ++++++- crates/tools/src/handlers/file_write.rs | 78 ++++++++- crates/tui/src/chatwidget.rs | 62 +++++++ crates/tui/src/chatwidget_tests.rs | 220 ++++++++++++++++++++++++ crates/tui/src/worker.rs | 151 +++++++++++++++- 9 files changed, 752 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b37bc2e..96e1ef3e 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/crates/server/src/projection.rs b/crates/server/src/projection.rs index d5223086..430b67ad 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -3,6 +3,7 @@ use devo_core::{ 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}; @@ -219,6 +220,12 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Option { 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, @@ -501,4 +565,91 @@ mod tests { }; 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 ffe710b4..26dcd4d3 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use tokio::sync::mpsc; use super::*; use crate::{FileChangePayload, TurnPlanStepPayload, TurnPlanUpdatedPayload}; +use devo_utils::git_op::extract_paths_from_patch; struct PendingToolCall { item_id: ItemId, @@ -60,7 +61,7 @@ fn is_unified_exec_tool(name: &str) -> bool { } fn is_file_change_tool(name: &str) -> bool { - matches!(name, "apply_patch") + matches!(name, "apply_patch" | "write") } fn is_plan_tool(name: &str) -> bool { @@ -534,8 +535,10 @@ impl ServerRuntime { content: "\n".repeat(deletions as usize), }, "update" | "move" => devo_protocol::protocol::FileChange::Update { - unified_diff: output_json + 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(), @@ -550,6 +553,30 @@ impl ServerRuntime { 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( @@ -1332,8 +1359,9 @@ mod tests { } #[test] - fn file_change_tool_detection_matches_apply_patch() { + 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")); } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index f2868e5c..234ae8ca 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 545c3aac..73e79c1a 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -80,12 +80,63 @@ 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 patch = diffy::create_patch(&old_content, &new_content); + let patch_text = diffy::PatchFormatter::new().fmt_patch(&patch).to_string(); + let patch_body = patch_text + .lines() + .filter(|line| !line.starts_with("--- ") && !line.starts_with("+++ ")) + .collect::>() + .join("\n"); + format!( + "diff --git a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n{1}", + 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, @@ -980,5 +1031,18 @@ hello assert_eq!(files[2]["deletions"], 1); assert_eq!(files[3]["additions"], 1); assert_eq!(files[3]["deletions"], 1); + + let diff = metadata["diff"].as_str().expect("diff metadata"); + let patch = diffy::Patch::from_str(diff).expect("metadata 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), (3, 3)); } } diff --git a/crates/tools/src/handlers/file_write.rs b/crates/tools/src/handlers/file_write.rs index 5cba7443..837e34ce 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,67 @@ 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("--- a/foo.txt")); + assert!(metadata["diff"].as_str().unwrap_or_default().contains("+++ b/foo.txt")); + } +} diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 78c98e46..cfa21af4 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -833,6 +833,14 @@ impl ChatWidget { 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() { @@ -919,6 +927,60 @@ impl ChatWidget { 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; diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 94b83e1b..ae956dd0 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -3394,6 +3394,8 @@ fn explored_group_in_history_can_finish_late_completions() { 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)); } @@ -3463,3 +3465,221 @@ fn patch_applied_event_renders_edited_block() { ); assert!(blob.contains("▌ Edited") || blob.contains("▌ Added")); } + +#[test] +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, PathBuf::from(".")); + + 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, + }, + ); + + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + blob.contains("(+1 -1)"), + "full git-style apply_patch diff should report non-zero counts:\n{blob}" + ); + 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!(crate::diff_render::calculate_add_remove_from_diff(diff), (1, 1)); +} + +#[test] +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 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 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!( + !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 patch_applied_event_with_diff_only_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!( + !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 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!( + 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/worker.rs b/crates/tui/src/worker.rs index 2268e759..d3ec3fcc 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -2055,7 +2055,7 @@ fn plan_event_from_tool_result(payload: &ToolResultPayload) -> Option Option { - if payload.tool_name.as_deref()? != "apply_patch" { + if !matches!(payload.tool_name.as_deref()?, "apply_patch" | "write") { return None; } let files = payload.content.get("files")?.as_array()?; @@ -2079,9 +2079,10 @@ fn patch_event_from_tool_result(payload: &ToolResultPayload) -> Option devo_protocol::protocol::FileChange::Update { - unified_diff: payload - .content + 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(), @@ -2497,6 +2498,150 @@ mod tests { 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 { From 691ad39355cc52334c76ad866426c5837ecb73af Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sun, 17 May 2026 13:38:17 +0800 Subject: [PATCH 6/8] tui: prevent approval transcript duplication --- crates/tui/src/chatwidget.rs | 42 ++++++++++++++++++-- crates/tui/src/chatwidget_tests.rs | 62 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index cfa21af4..56566c7d 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -363,6 +363,8 @@ pub(crate) struct ChatWidget { last_plan_progress: Option<(usize, usize)>, queued_count: usize, active_turn_id: Option, + saw_server_assistant_lifecycle: bool, + saw_server_reasoning_lifecycle: bool, pending_approval: Option, permission_preset: devo_protocol::PermissionPreset, busy: bool, @@ -1104,6 +1106,8 @@ impl ChatWidget { last_plan_progress: None, queued_count: 0, active_turn_id: None, + saw_server_assistant_lifecycle: false, + saw_server_reasoning_lifecycle: false, pending_approval: None, permission_preset: initial_permission_preset, busy: false, @@ -1488,6 +1492,8 @@ impl ChatWidget { .. } => { self.active_turn_id = Some(turn_id); + self.saw_server_assistant_lifecycle = false; + self.saw_server_reasoning_lifecycle = false; self.update_session_request_model(model); self.thinking_selection = thinking; self.session.reasoning_effort = reasoning_effort; @@ -1499,6 +1505,10 @@ impl ChatWidget { } WorkerEvent::TextItemStarted { item_id, kind } => { self.flush_active_cell(); + match kind { + TextItemKind::Assistant => self.saw_server_assistant_lifecycle = true, + TextItemKind::Reasoning => self.saw_server_reasoning_lifecycle = true, + } self.start_text_item(ActiveTextItemId::Server(item_id), kind); self.set_status_message(match kind { TextItemKind::Assistant => "Generating", @@ -1528,7 +1538,9 @@ impl ChatWidget { }); } WorkerEvent::TextDelta(text) => { - if !self.has_server_active_item(TextItemKind::Assistant) { + if !self.saw_server_assistant_lifecycle + && !self.has_server_active_item(TextItemKind::Assistant) + { self.flush_active_cell(); self.push_text_item_delta( ActiveTextItemId::Legacy(TextItemKind::Assistant), @@ -1539,7 +1551,9 @@ impl ChatWidget { self.set_status_message("Generating"); } WorkerEvent::ReasoningDelta(text) => { - if !self.has_server_active_item(TextItemKind::Reasoning) { + if !self.saw_server_reasoning_lifecycle + && !self.has_server_active_item(TextItemKind::Reasoning) + { self.flush_active_cell(); self.push_text_item_delta( ActiveTextItemId::Legacy(TextItemKind::Reasoning), @@ -1550,7 +1564,13 @@ impl ChatWidget { self.set_status_message("Thinking"); } WorkerEvent::AssistantMessageCompleted(text) => { - if !self.has_server_active_item(TextItemKind::Assistant) { + if !self.saw_server_assistant_lifecycle + && !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, @@ -1560,7 +1580,13 @@ impl ChatWidget { self.set_status_message("Generating"); } WorkerEvent::ReasoningCompleted(text) => { - if !self.has_server_active_item(TextItemKind::Reasoning) { + if !self.saw_server_reasoning_lifecycle + && !self.has_server_active_item(TextItemKind::Reasoning) + && !self + .active_text_items + .iter() + .any(|item| item.kind == TextItemKind::Reasoning) + { self.complete_text_item( ActiveTextItemId::Legacy(TextItemKind::Reasoning), TextItemKind::Reasoning, @@ -1876,6 +1902,8 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.pending_approval = None; + self.saw_server_assistant_lifecycle = false; + self.saw_server_reasoning_lifecycle = false; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1921,6 +1949,8 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.pending_approval = None; + self.saw_server_assistant_lifecycle = false; + self.saw_server_reasoning_lifecycle = false; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1995,6 +2025,8 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.active_text_items.clear(); + self.saw_server_assistant_lifecycle = false; + self.saw_server_reasoning_lifecycle = false; self.stream_chunking_policy.reset(); self.busy = false; self.turn_count = 0; @@ -2039,6 +2071,8 @@ impl ChatWidget { self.history.clear(); self.next_history_flush_index = 0; self.active_text_items.clear(); + self.saw_server_assistant_lifecycle = false; + self.saw_server_reasoning_lifecycle = false; self.stream_chunking_policy.reset(); self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index ae956dd0..43ff001d 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -260,6 +260,68 @@ fn approval_request_renders_bottom_pane_menu_and_accepts_once() { ); } +#[test] +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, PathBuf::from(".")); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let item_id = ItemId::new(); + let text = "明白,我来随便加点内容,测试一下 apply_patch。".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(), + )); + + 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 approval_request_bottom_pane_menu_denies_with_n_shortcut() { let model = Model { From 249df99c42dc614d8a3f6f5e935ce9f2de898e52 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 18 May 2026 00:32:18 +0800 Subject: [PATCH 7/8] tui: fix resume picker and transcript backtrack rendering --- crates/tools/src/apply_patch.rs | 59 ++- crates/tools/src/handlers/file_write.rs | 4 +- crates/tui/src/bottom_pane/mod.rs | 37 ++ crates/tui/src/chatwidget.rs | 545 +++++++++++++++++++----- crates/tui/src/chatwidget_tests.rs | 516 ++++++++++++++++++++++ crates/tui/src/host.rs | 119 ++++++ crates/tui/src/host_overlay.rs | 14 + crates/tui/src/pager_overlay.rs | 417 +++++++++++++++++- 8 files changed, 1554 insertions(+), 157 deletions(-) diff --git a/crates/tools/src/apply_patch.rs b/crates/tools/src/apply_patch.rs index 73e79c1a..82ba089b 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -116,15 +116,25 @@ pub(crate) async fn exec_apply_patch( ) } PatchKind::Update | PatchKind::Move => { - let patch = diffy::create_patch(&old_content, &new_content); - let patch_text = diffy::PatchFormatter::new().fmt_patch(&patch).to_string(); - let patch_body = patch_text - .lines() - .filter(|line| !line.starts_with("--- ") && !line.starts_with("+++ ")) + 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}", + "diff --git a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n{1}\n", relative_path, patch_body ) } @@ -142,7 +152,10 @@ pub(crate) async fn exec_apply_patch( "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 { @@ -1032,17 +1045,25 @@ hello assert_eq!(files[3]["additions"], 1); assert_eq!(files[3]["deletions"], 1); - let diff = metadata["diff"].as_str().expect("diff metadata"); - let patch = diffy::Patch::from_str(diff).expect("metadata 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), (3, 3)); + 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/handlers/file_write.rs b/crates/tools/src/handlers/file_write.rs index 837e34ce..9f519005 100644 --- a/crates/tools/src/handlers/file_write.rs +++ b/crates/tools/src/handlers/file_write.rs @@ -126,7 +126,7 @@ mod tests { 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("--- a/foo.txt")); - assert!(metadata["diff"].as_str().unwrap_or_default().contains("+++ b/foo.txt")); + 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/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index 021c3232..fef348d3 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -517,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 56566c7d..04f5510f 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -5,6 +5,7 @@ //! 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; @@ -56,9 +57,9 @@ 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::SessionListEntry; use crate::events::PlanStep; use crate::events::PlanStepStatus; +use crate::events::SessionListEntry; use crate::events::TextItemKind; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; @@ -73,8 +74,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::ScrollbackLine; use crate::markdown::append_markdown; -use crate::render::renderable::Renderable; 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; use crate::streaming::chunking::AdaptiveChunkingPolicy; @@ -84,9 +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; -use devo_protocol::{SessionHistoryItem, SessionHistoryMetadata, SessionPlanStepStatus}; /// Common initialization parameters shared by `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { @@ -151,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)] @@ -206,6 +211,7 @@ enum OnboardingStep { struct ResumeBrowserState { sessions: Vec, selection: usize, + scroll_offset: usize, } #[derive(Debug, Clone)] @@ -353,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, @@ -363,8 +370,7 @@ pub(crate) struct ChatWidget { last_plan_progress: Option<(usize, usize)>, queued_count: usize, active_turn_id: Option, - saw_server_assistant_lifecycle: bool, - saw_server_reasoning_lifecycle: bool, + committed_server_assistant_in_turn: bool, pending_approval: Option, permission_preset: devo_protocol::PermissionPreset, busy: bool, @@ -499,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 { @@ -793,7 +814,11 @@ impl ChatWidget { let metadata_owned_ids: HashSet = history_items .iter() - .filter_map(|item| item.tool_call_id.clone().filter(|_| item.metadata.is_some())) + .filter_map(|item| { + item.tool_call_id + .clone() + .filter(|_| item.metadata.is_some()) + }) .collect(); let mut consumed_indexes = HashSet::new(); @@ -812,15 +837,26 @@ impl ChatWidget { 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(), + 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 } => { @@ -1096,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, @@ -1106,8 +1143,7 @@ impl ChatWidget { last_plan_progress: None, queued_count: 0, active_turn_id: None, - saw_server_assistant_lifecycle: false, - saw_server_reasoning_lifecycle: false, + committed_server_assistant_in_turn: false, pending_approval: None, permission_preset: initial_permission_preset, busy: false, @@ -1132,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; @@ -1492,8 +1540,7 @@ impl ChatWidget { .. } => { self.active_turn_id = Some(turn_id); - self.saw_server_assistant_lifecycle = false; - self.saw_server_reasoning_lifecycle = false; + self.committed_server_assistant_in_turn = false; self.update_session_request_model(model); self.thinking_selection = thinking; self.session.reasoning_effort = reasoning_effort; @@ -1505,10 +1552,6 @@ impl ChatWidget { } WorkerEvent::TextItemStarted { item_id, kind } => { self.flush_active_cell(); - match kind { - TextItemKind::Assistant => self.saw_server_assistant_lifecycle = true, - TextItemKind::Reasoning => self.saw_server_reasoning_lifecycle = true, - } self.start_text_item(ActiveTextItemId::Server(item_id), kind); self.set_status_message(match kind { TextItemKind::Assistant => "Generating", @@ -1538,9 +1581,7 @@ impl ChatWidget { }); } WorkerEvent::TextDelta(text) => { - if !self.saw_server_assistant_lifecycle - && !self.has_server_active_item(TextItemKind::Assistant) - { + if !self.has_server_active_item(TextItemKind::Assistant) { self.flush_active_cell(); self.push_text_item_delta( ActiveTextItemId::Legacy(TextItemKind::Assistant), @@ -1551,9 +1592,7 @@ impl ChatWidget { self.set_status_message("Generating"); } WorkerEvent::ReasoningDelta(text) => { - if !self.saw_server_reasoning_lifecycle - && !self.has_server_active_item(TextItemKind::Reasoning) - { + if !self.has_server_active_item(TextItemKind::Reasoning) { self.flush_active_cell(); self.push_text_item_delta( ActiveTextItemId::Legacy(TextItemKind::Reasoning), @@ -1564,7 +1603,7 @@ impl ChatWidget { self.set_status_message("Thinking"); } WorkerEvent::AssistantMessageCompleted(text) => { - if !self.saw_server_assistant_lifecycle + if !self.committed_server_assistant_in_turn && !self.has_server_active_item(TextItemKind::Assistant) && !self .active_text_items @@ -1580,13 +1619,7 @@ impl ChatWidget { self.set_status_message("Generating"); } WorkerEvent::ReasoningCompleted(text) => { - if !self.saw_server_reasoning_lifecycle - && !self.has_server_active_item(TextItemKind::Reasoning) - && !self - .active_text_items - .iter() - .any(|item| item.kind == TextItemKind::Reasoning) - { + if !self.has_server_active_item(TextItemKind::Reasoning) { self.complete_text_item( ActiveTextItemId::Legacy(TextItemKind::Reasoning), TextItemKind::Reasoning, @@ -1603,9 +1636,12 @@ impl ChatWidget { 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 { .. })); + && parsed.iter().all(|parsed| { + !matches!( + parsed, + devo_protocol::parse_command::ParsedCommand::Unknown { .. } + ) + }); if exec_like { if let Some(cell) = self .active_cell @@ -1720,18 +1756,17 @@ impl ChatWidget { } else { DotStatus::Completed }; - 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, - }); + 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 - { + if resolved_title.exec_like { let output = CommandOutput { exit_code: if is_error { 1 } else { 0 }, aggregated_output: preview.clone(), @@ -1765,10 +1800,12 @@ impl ChatWidget { } } 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) - }) + 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(); @@ -1814,10 +1851,7 @@ impl ChatWidget { self.set_status_message("Plan updated"); } WorkerEvent::PatchApplied { changes } => { - self.add_to_history(history_cell::new_patch_event( - changes, - &self.session.cwd, - )); + self.add_to_history(history_cell::new_patch_event(changes, &self.session.cwd)); self.set_status_message("Patch applied"); } WorkerEvent::ApprovalRequest { @@ -1902,8 +1936,7 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.pending_approval = None; - self.saw_server_assistant_lifecycle = false; - self.saw_server_reasoning_lifecycle = false; + self.committed_server_assistant_in_turn = false; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1949,8 +1982,7 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.pending_approval = None; - self.saw_server_assistant_lifecycle = false; - self.saw_server_reasoning_lifecycle = false; + self.committed_server_assistant_in_turn = false; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -2025,8 +2057,7 @@ impl ChatWidget { self.active_tool_calls.clear(); self.pending_tool_calls.clear(); self.active_text_items.clear(); - self.saw_server_assistant_lifecycle = false; - self.saw_server_reasoning_lifecycle = false; + self.committed_server_assistant_in_turn = false; self.stream_chunking_policy.reset(); self.busy = false; self.turn_count = 0; @@ -2071,8 +2102,7 @@ impl ChatWidget { self.history.clear(); self.next_history_flush_index = 0; self.active_text_items.clear(); - self.saw_server_assistant_lifecycle = false; - self.saw_server_reasoning_lifecycle = false; + 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; @@ -2169,13 +2199,11 @@ impl ChatWidget { .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(), - ]), - ]; + 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() { @@ -2750,6 +2778,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 { @@ -3327,13 +3358,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; @@ -3386,6 +3463,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() } @@ -3405,6 +3498,22 @@ impl ChatWidget { 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 { @@ -3736,6 +3845,7 @@ impl ChatWidget { self.resume_browser = Some(ResumeBrowserState { sessions, selection, + scroll_offset: 0, }); self.set_status_message("Resume session"); } @@ -3747,28 +3857,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 => { @@ -3787,6 +3938,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 { @@ -3806,26 +4097,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", @@ -3837,22 +4140,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() @@ -3860,7 +4171,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 43ff001d..6cb2c005 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -4,6 +4,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; +use ratatui::text::Line; use devo_protocol::ApprovalDecisionValue; use devo_protocol::ApprovalScopeValue; use devo_protocol::InputItem; @@ -187,6 +188,170 @@ fn user_prompt_multiline_has_no_extra_blank_prefix_rows_and_consistent_prefix_te assert_eq!(user_lines[4], "▌ "); } +#[test] +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, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.restore_user_message_to_composer(crate::chatwidget::UserMessage::from( + "previous message", + )); + + let rendered = rendered_rows(&widget, 80, 12).join("\n"); + assert!(rendered.contains("previous message"), "composer should show restored text:\n{rendered}"); +} + +#[test] +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, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.submit_text("previous message".to_string()); + let _ = widget.drain_scrollback_lines(80); + + 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!( + user_cell.user_message.expect("user payload").text, + "previous message" + ); +} + +#[test] +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, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + 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 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(); + 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 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, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + 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 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}" + ); + + 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 restoring_previous_message_truncates_later_transcript_history() { + 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.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); + + 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 esc_backtrack_hint_is_shown_before_restore() { + 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.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 resume_command_opens_loading_browser_immediately() { let model = Model { @@ -209,6 +374,357 @@ fn resume_command_opens_loading_browser_immediately() { ); } +#[test] +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, _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_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!widget.is_resume_browser_open()); + + widget.handle_app_event(AppEvent::Command(AppCommand::RunUserShellCommand { + command: "session list".to_string(), + })); + 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 resume_browser_clips_sessions_to_viewport_height() { + 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 = (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 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 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()); + + 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 resume_browser_keeps_selection_visible_when_navigating_down() { + 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 = (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); + + for _ in 0..11 { + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + + 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 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, 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); + + 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 event = app_event_rx + .try_recv() + .expect("resume selection should emit switch command"); + assert_eq!( + event, + AppEvent::Command(AppCommand::switch_session(expected)) + ); +} + +#[test] +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, _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::PageDown, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(3)); + + 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 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, 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); + + widget.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(0)); + + widget.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(3)); + + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(3)); +} + +#[test] +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 (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 resume_browser_title_uses_ascii_ellipsis_when_too_long() { + 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: "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}"); +} + +#[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}"); +} + +#[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 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, 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 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) + }); + } + } + 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 approval_request_renders_bottom_pane_menu_and_accepts_once() { let model = Model { diff --git a/crates/tui/src/host.rs b/crates/tui/src/host.rs index c534a490..a198a94e 100644 --- a/crates/tui/src/host.rs +++ b/crates/tui/src/host.rs @@ -58,6 +58,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 +75,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, @@ -309,6 +318,20 @@ fn handle_tui_event( }; if loop_state.overlay.is_active() { + if let TuiEvent::Key(key_event) = tui_event + && matches!(key_event.kind, crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat) + && key_event.code == KeyCode::Enter + && let Some(transcript) = loop_state.overlay.transcript_mut() + && let Some(user_message) = transcript.selected_user_message() + { + if let Some(selected_history_position) = transcript.selected_user_history_position() { + chat_widget + .truncate_history_to_user_turn_count(selected_history_position.saturating_add(1)); + } + chat_widget.restore_user_message_to_composer(user_message); + loop_state.overlay.close(tui)?; + return Ok(LoopAction::Continue); + } if matches!(tui_event, TuiEvent::Draw) { chat_widget.pre_draw_tick(); } @@ -398,6 +421,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) => { @@ -430,6 +479,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, @@ -772,4 +846,49 @@ 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 + ); + } + } diff --git a/crates/tui/src/host_overlay.rs b/crates/tui/src/host_overlay.rs index e8aa7777..87fe7d7a 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 d851f8f8..f7cec0c7 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,89 @@ 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 +635,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 +667,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 +789,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 +800,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 +828,8 @@ mod tests { TranscriptOverlayCell { lines: vec![Line::from(text.into())], is_stream_continuation: false, + user_message: None, + is_selected_user: false, } } @@ -752,4 +935,194 @@ 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)); + } } From 2882a6205a3dd7ded3b031bfeefd87d55eac3a90 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 18 May 2026 01:11:20 +0800 Subject: [PATCH 8/8] tui: improve resume picker and exit summaries --- crates/cli/src/agent_command.rs | 9 +- crates/cli/src/main.rs | 167 ++++++++++++++- crates/protocol/src/event.rs | 4 +- crates/protocol/src/session.rs | 2 +- crates/server/src/projection.rs | 12 +- crates/server/src/runtime/turn_exec.rs | 132 ++++++++---- crates/tools/src/apply_patch.rs | 14 +- crates/tools/src/handlers/file_write.rs | 26 ++- crates/tui/src/app.rs | 7 + crates/tui/src/chatwidget.rs | 1 + crates/tui/src/chatwidget_tests.rs | 271 +++++++++++++++++------- crates/tui/src/events.rs | 6 +- crates/tui/src/exec_cell/mod.rs | 2 +- crates/tui/src/host.rs | 58 ++++- crates/tui/src/pager_overlay.rs | 7 +- crates/tui/src/worker.rs | 92 ++++++-- 16 files changed, 634 insertions(+), 176 deletions(-) diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs index 448331e7..edb6141d 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 9c17f582..f8744624 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/protocol/src/event.rs b/crates/protocol/src/event.rs index cfe56f99..81e3e5a6 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -1,10 +1,10 @@ use serde::{Deserialize, Serialize}; use smol_str::SmolStr; -use crate::session::{SessionMetadata, SessionRuntimeStatus}; -use crate::turn::TurnMetadata; 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}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs index 8171abf4..80b440f7 100644 --- a/crates/protocol/src/session.rs +++ b/crates/protocol/src/session.rs @@ -6,10 +6,10 @@ use chrono::Utc; use serde::Deserialize; use serde::Serialize; -use crate::parse_command::ParsedCommand; use crate::ReasoningEffort; use crate::SessionId; use crate::SessionTitleState; +use crate::parse_command::ParsedCommand; use crate::protocol::FileChange; use crate::turn::TurnMetadata; diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index 430b67ad..72decfe1 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -6,7 +6,9 @@ use devo_protocol::{SessionHistoryMetadata, SessionPlanStep, SessionPlanStepStat 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}; +use crate::session::{ + SessionHistoryItem, SessionHistoryItemKind, SessionMetadata, SessionRuntimeStatus, +}; use crate::turn::TurnMetadata; /// Projects a canonical core session record into the API-visible session summary. @@ -275,7 +277,6 @@ 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 @@ -445,9 +446,9 @@ mod tests { use super::history_item_from_turn_item; use crate::session::SessionHistoryItemKind; + use devo_core::TurnItem; use devo_core::{CommandExecutionItem, TextItem, ToolCallItem, ToolResultItem}; use devo_protocol::{SessionHistoryMetadata, SessionPlanStepStatus}; - use devo_core::TurnItem; #[test] fn history_projection_prefers_tool_result_display_content() { @@ -644,8 +645,9 @@ mod tests { else { panic!("expected edited metadata"); }; - let devo_protocol::protocol::FileChange::Update { unified_diff, .. } = - changes.get(&std::path::PathBuf::from("foo.txt")).expect("update change") + let devo_protocol::protocol::FileChange::Update { unified_diff, .. } = changes + .get(&std::path::PathBuf::from("foo.txt")) + .expect("update change") else { panic!("expected update change"); }; diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index 26dcd4d3..f3e08144 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -1,10 +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, @@ -399,7 +399,9 @@ impl ServerRuntime { tool_name: name.clone(), command: command.clone(), source: devo_protocol::protocol::ExecCommandSource::Agent, - command_actions: command_actions_from_tool_input(&name, &command, &input), + command_actions: command_actions_from_tool_input( + &name, &command, &input, + ), output: None, is_error: false, }) @@ -414,7 +416,9 @@ impl ServerRuntime { tool_call_id: id.clone(), tool_name: name.clone(), parameters: input.clone(), - command_actions: command_actions_from_tool_input(&name, &command, &input), + command_actions: command_actions_from_tool_input( + &name, &command, &input, + ), }) .expect("serialize tool call payload") }; @@ -452,11 +456,14 @@ impl ServerRuntime { && 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())) + 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") @@ -495,8 +502,14 @@ impl ServerRuntime { .into_iter() .filter_map(|item| { Some(TurnPlanStepPayload { - step: item.get("step")?.as_str()?.to_string(), - status: item.get("status")?.as_str()?.to_string(), + step: item + .get("step")? + .as_str()? + .to_string(), + status: item + .get("status")? + .as_str()? + .to_string(), }) }) .collect(), @@ -510,11 +523,14 @@ impl ServerRuntime { && 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())) + 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") @@ -523,31 +539,42 @@ impl ServerRuntime { .unwrap_or_default() .into_iter() .filter_map(|file| { - let path = std::path::PathBuf::from(file.get("path")?.as_str()?); + 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 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), - }, + "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)) @@ -606,11 +633,14 @@ impl ServerRuntime { if pending.is_command_execution { let tool_name = tool_name.clone().unwrap_or_default(); 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())) + 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 { @@ -618,7 +648,11 @@ impl ServerRuntime { 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), + command_actions: command_actions_from_tool_input( + &tool_name, + &pending.command, + &pending.input, + ), output: Some(output.clone()), is_error, }) @@ -679,11 +713,14 @@ impl ServerRuntime { tool_call_id: tool_use_id.clone(), tool_name: tool_name.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())) + 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, @@ -692,11 +729,14 @@ impl ServerRuntime { tool_call_id: tool_use_id.clone(), tool_name, 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())) + 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, diff --git a/crates/tools/src/apply_patch.rs b/crates/tools/src/apply_patch.rs index 82ba089b..84e9b056 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -1052,16 +1052,16 @@ hello .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 { + 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/handlers/file_write.rs b/crates/tools/src/handlers/file_write.rs index 9f519005..da3b5340 100644 --- a/crates/tools/src/handlers/file_write.rs +++ b/crates/tools/src/handlers/file_write.rs @@ -67,7 +67,11 @@ fn resolve_path(cwd: &std::path::Path, path: &str) -> PathBuf { 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 { +fn build_write_metadata( + path: &std::path::Path, + previous: Option<&str>, + content: &str, +) -> serde_json::Value { match previous { None => json!({ "diff": format!( @@ -117,16 +121,28 @@ mod tests { #[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"); + 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"); + 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 @@")); + 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/tui/src/app.rs b/crates/tui/src/app.rs index 8961058d..b4a25dff 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/chatwidget.rs b/crates/tui/src/chatwidget.rs index 04f5510f..5222f4ea 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -1532,6 +1532,7 @@ impl ChatWidget { pub(crate) fn handle_worker_event(&mut self, event: WorkerEvent) { match event { + WorkerEvent::SessionActivated { .. } => {} WorkerEvent::TurnStarted { model, thinking, diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 6cb2c005..32ac1077 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -4,7 +4,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use ratatui::text::Line; use devo_protocol::ApprovalDecisionValue; use devo_protocol::ApprovalScopeValue; use devo_protocol::InputItem; @@ -16,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; @@ -141,7 +141,8 @@ fn trim_trailing_blank_scrollback_lines( } fn line_texts(lines: Vec>) -> Vec { - lines.into_iter() + lines + .into_iter() .map(|line| { line.spans .into_iter() @@ -180,7 +181,11 @@ fn user_prompt_multiline_has_no_extra_blank_prefix_rows_and_consistent_prefix_te .filter(|line| line.starts_with("▌ ")) .collect(); - assert_eq!(user_lines.len(), 5, "unexpected user prompt rows: {user_lines:?}"); + 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"); @@ -197,12 +202,14 @@ fn restore_user_message_to_composer_restores_text() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.restore_user_message_to_composer(crate::chatwidget::UserMessage::from( - "previous message", - )); + widget + .restore_user_message_to_composer(crate::chatwidget::UserMessage::from("previous message")); let rendered = rendered_rows(&widget, 80, 12).join("\n"); - assert!(rendered.contains("previous message"), "composer should show restored text:\n{rendered}"); + assert!( + rendered.contains("previous message"), + "composer should show restored text:\n{rendered}" + ); } #[test] @@ -242,15 +249,15 @@ fn backtrack_preview_restore_latest_user_message() { widget.submit_text("second message".to_string()); let _ = widget.drain_scrollback_lines(80); - let mut overlay = crate::pager_overlay::Overlay::new_transcript( - widget.transcript_overlay_cells(80), - 80, - ); + 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(); - let selected = transcript.selected_user_message().expect("selected latest user"); + 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"); @@ -274,16 +281,16 @@ fn backtrack_preview_can_restore_previous_and_next_user_messages() { widget.submit_text("second message".to_string()); let _ = widget.drain_scrollback_lines(80); - let mut overlay = crate::pager_overlay::Overlay::new_transcript( - widget.transcript_overlay_cells(80), - 80, - ); + 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"); + 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!( @@ -292,7 +299,9 @@ fn backtrack_preview_can_restore_previous_and_next_user_messages() { ); transcript.select_next_user(); - let next = transcript.selected_user_message().expect("selected 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!( @@ -311,9 +320,13 @@ fn restoring_previous_message_truncates_later_transcript_history() { let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); widget.submit_text("first message".to_string()); - widget.add_to_history(crate::history_cell::PlainHistoryCell::new(vec![Line::from("assistant 1")])); + 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")])); + widget.add_to_history(crate::history_cell::PlainHistoryCell::new(vec![ + Line::from("assistant 2"), + ])); let _ = widget.drain_scrollback_lines(80); widget.truncate_history_to_user_turn_count(1); @@ -351,7 +364,6 @@ fn esc_backtrack_hint_is_shown_before_restore() { ); } - #[test] fn resume_command_opens_loading_browser_immediately() { let model = Model { @@ -421,9 +433,18 @@ fn resume_browser_clips_sessions_to_viewport_height() { 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}"); + 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] @@ -478,9 +499,18 @@ fn resume_browser_keeps_selection_visible_when_navigating_down() { 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}"); + 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] @@ -604,8 +634,14 @@ fn resume_browser_shows_position_and_scroll_progress() { 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}"); + 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] @@ -618,13 +654,17 @@ fn resume_browser_title_uses_ascii_ellipsis_when_too_long() { 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(), + 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}"); + assert!( + blob.contains("..."), + "expected ASCII ellipsis truncation in title column:\n{blob}" + ); } #[test] @@ -643,7 +683,10 @@ fn resume_browser_dash_only_title_is_truncated_with_ascii_ellipsis() { }]); let blob = rendered_rows(&widget, 54, 10).join("\n"); - assert!(blob.contains("..."), "expected dash-only title to be truncated with ASCII ellipsis:\n{blob}"); + assert!( + blob.contains("..."), + "expected dash-only title to be truncated with ASCII ellipsis:\n{blob}" + ); } #[test] @@ -662,8 +705,14 @@ fn resume_browser_cjk_title_truncates_by_display_width() { }]); 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}"); + 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] @@ -722,7 +771,10 @@ fn resume_browser_cjk_and_ascii_titles_keep_session_id_column_aligned() { } 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"); + assert_eq!( + cjk_col, ascii_col, + "expected Session ID column alignment across CJK and ASCII rows" + ); } #[test] @@ -1340,10 +1392,22 @@ fn plan_update_updates_progress_and_history() { 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("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(" ✔ Inspect implementation")) + ); assert!(lines.iter().any(|line| line.contains(" → Patch runtime"))); } @@ -1445,7 +1509,10 @@ fn session_switch_restores_explored_metadata_into_history() { blob.contains("Explored") || blob.contains("Exploring"), "expected explored block after resume, got:\n{blob}" ); - assert!(blob.contains("Read foo.txt"), "expected read summary, got:\n{blob}"); + assert!( + blob.contains("Read foo.txt"), + "expected read summary, got:\n{blob}" + ); } #[test] @@ -1564,7 +1631,10 @@ fn session_switch_merges_consecutive_explored_items() { 1, "expected one merged explored block, got:\n{blob}" ); - assert!(blob.contains("Read worker.rs"), "expected read entry, got:\n{blob}"); + assert!( + blob.contains("Read worker.rs"), + "expected read entry, got:\n{blob}" + ); assert!( blob.contains("Search command_actions in crates/tui/src/worker.rs"), "expected search entry, got:\n{blob}" @@ -1608,8 +1678,14 @@ fn session_switch_restores_error_via_tool_result_cell_style() { }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); - assert!(blob.contains("Ran bash error"), "expected tool-result style title, got:\n{blob}"); - assert!(blob.contains("permission denied"), "expected tool-result body, got:\n{blob}"); + assert!( + blob.contains("Ran bash error"), + "expected tool-result style title, got:\n{blob}" + ); + assert!( + blob.contains("permission denied"), + "expected tool-result body, got:\n{blob}" + ); } #[test] @@ -1666,7 +1742,10 @@ fn live_and_resume_error_share_same_rendering_chain() { .collect::>() .join("\n"); - assert_eq!(live_blob, resume_blob, "live and resume error cells diverged"); + assert_eq!( + live_blob, resume_blob, + "live and resume error cells diverged" + ); } #[test] @@ -3529,10 +3608,12 @@ fn glob_tool_call_renders_as_explored_group_in_viewport() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "glob **/Cargo.toml in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/Cargo.toml in crates".to_string(), - path: Some("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(), @@ -3555,7 +3636,10 @@ fn glob_tool_call_renders_as_explored_group_in_viewport() { .join("\n"); assert!(display.contains("Explored") || display.contains("Exploring")); - assert!(display.contains("List crates"), "expected list summary, got:\n{display}"); + assert!( + display.contains("List crates"), + "expected list summary, got:\n{display}" + ); } #[test] @@ -3624,10 +3708,12 @@ fn merged_explored_group_becomes_explored_after_all_results_arrive() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/plan.rs in crates".to_string(), - path: Some("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 { @@ -3688,10 +3774,12 @@ fn live_viewport_shows_explored_group_while_active() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/plan.rs in crates".to_string(), - path: Some("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 @@ -3745,10 +3833,12 @@ fn reasoning_start_closes_current_explored_group() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/plan.rs in crates".to_string(), - path: Some("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 @@ -3795,10 +3885,12 @@ fn assistant_text_start_closes_current_explored_group() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/plan.rs in crates".to_string(), - path: Some("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 @@ -3841,10 +3933,12 @@ fn merged_explored_group_stays_completed_when_tool_results_arrive_after_tool_cal widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/plan.rs in crates".to_string(), - path: Some("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 { @@ -3919,10 +4013,12 @@ fn explored_group_in_history_can_finish_late_completions() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "glob **/plan.rs in crates".to_string(), - path: Some("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(), @@ -3970,12 +4066,24 @@ fn explored_group_in_history_can_finish_late_completions() { #[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( + "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)); + 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] @@ -4075,17 +4183,22 @@ fn apply_patch_style_full_git_diff_reports_non_zero_counts() { ); } - #[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!(crate::diff_render::calculate_add_remove_from_diff(diff), (1, 1)); + assert_eq!( + crate::diff_render::calculate_add_remove_from_diff(diff), + (1, 1) + ); } #[test] 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)); + assert_eq!( + crate::diff_render::calculate_add_remove_from_diff(diff), + (1, 1) + ); } #[test] diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 93e43c79..3dd478e1 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -1,14 +1,14 @@ -use std::time::Instant; use std::collections::HashMap; use std::path::PathBuf; +use std::time::Instant; use crate::app_command::InputHistoryDirection; use devo_core::ItemId; use devo_core::SessionId; -use devo_protocol::parse_command::ParsedCommand; 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; @@ -68,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, diff --git a/crates/tui/src/exec_cell/mod.rs b/crates/tui/src/exec_cell/mod.rs index 88ef6fb5..72e2b2e6 100644 --- a/crates/tui/src/exec_cell/mod.rs +++ b/crates/tui/src/exec_cell/mod.rs @@ -4,9 +4,9 @@ mod spinner; pub(crate) use model::CommandOutput; pub(crate) use model::ExecCell; -pub(crate) use render::new_active_exec_command; 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/host.rs b/crates/tui/src/host.rs index a198a94e..23598e1d 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, @@ -157,6 +159,7 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result Result { 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() { @@ -623,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 { .. } @@ -864,9 +891,7 @@ mod tests { assert_eq!( determine_esc_backtrack_action( - esc_press, - false, - /*is_normal_backtrack_mode*/ true, + esc_press, false, /*is_normal_backtrack_mode*/ true, /*composer_is_empty*/ true, ), EscBacktrackAction::PrimeHint @@ -882,13 +907,26 @@ mod tests { ); assert_eq!( determine_esc_backtrack_action( - esc_press, - true, - /*is_normal_backtrack_mode*/ true, + 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/pager_overlay.rs b/crates/tui/src/pager_overlay.rs index f7cec0c7..3b016b49 100644 --- a/crates/tui/src/pager_overlay.rs +++ b/crates/tui/src/pager_overlay.rs @@ -611,7 +611,8 @@ impl TranscriptOverlay { 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); + cell.is_selected_user = + cell.user_message.is_some() && self.selected_user_index == Some(idx); } self.rebuild_renderables(); } @@ -1035,7 +1036,9 @@ mod tests { ); overlay.begin_backtrack_preview(); - let selected = overlay.selected_user_message().expect("selected latest user"); + let selected = overlay + .selected_user_message() + .expect("selected latest user"); assert_eq!(selected.text, "user 2"); } diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index d3ec3fcc..ab574a9f 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; @@ -49,8 +51,6 @@ use devo_server::TurnEventPayload; use devo_server::TurnInterruptParams; use devo_server::TurnStartParams; use devo_server::TurnSteerParams; -use devo_protocol::SessionHistoryMetadata; -use devo_protocol::SessionPlanStepStatus; use crate::app_command::InputHistoryDirection; use crate::events::PlanStep; @@ -71,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. @@ -426,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() => { @@ -450,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, @@ -1638,10 +1698,14 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe payload, .. } => { - let Ok(payload) = serde_json::from_value::(payload) else { + let Ok(payload) = serde_json::from_value::(payload) + else { return; }; - let changes = payload.changes.into_iter().collect::>(); + let changes = payload + .changes + .into_iter() + .collect::>(); let _ = event_tx.send(WorkerEvent::PatchApplied { changes }); } ItemEnvelope { @@ -2271,9 +2335,9 @@ mod tests { use super::project_history_items; use super::summarize_tool_call; use super::truncate_tool_output; - use crate::events::SessionListEntry; use crate::events::PlanStep; use crate::events::PlanStepStatus; + use crate::events::SessionListEntry; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; use crate::events::WorkerEvent; @@ -2492,7 +2556,8 @@ mod tests { &event_tx, ); - let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") else { + 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"))); @@ -2536,7 +2601,8 @@ mod tests { &event_tx, ); - let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") else { + 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"))); @@ -2585,7 +2651,8 @@ mod tests { &event_tx, ); - let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") else { + 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"))); @@ -2630,11 +2697,13 @@ mod tests { &event_tx, ); - let WorkerEvent::PatchApplied { changes } = event_rx.try_recv().expect("worker event") else { + 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") + let devo_protocol::protocol::FileChange::Update { unified_diff, .. } = changes + .get(&std::path::PathBuf::from("update.txt")) + .expect("update change") else { panic!("expected update change"); }; @@ -2672,7 +2741,6 @@ mod tests { ); } - #[test] fn session_list_entries_keep_title_before_identifier() { let active_session_id = SessionId::new();