diff --git a/Cargo.lock b/Cargo.lock index 3135c868..f5f7a35a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1617,6 +1617,7 @@ dependencies = [ "devo-safety", "devo-skills", "devo-tools", + "devo-util-paths", "devo-util-process", "diffy", "futures", diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index ba5e50e6..be6b26c1 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -43,10 +43,6 @@ use crate::upsert_user_auth_api_key; use crate::write_atomic; use crate::write_provider_config; -pub const DESKTOP_NETWORK_PROXY_MODE_ENV: &str = "DEVO_DESKTOP_NETWORK_PROXY_MODE"; -pub const DESKTOP_NETWORK_PROXY_URL_ENV: &str = "DEVO_DESKTOP_NETWORK_PROXY_URL"; -pub const DESKTOP_NETWORK_NO_PROXY_ENV: &str = "DEVO_DESKTOP_NETWORK_NO_PROXY"; - /// Stores the fully normalized runtime configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AppConfig { @@ -590,56 +586,6 @@ pub struct FileSystemAppConfigLoader { config_folder_home: PathBuf, /// Command-line overrides applied on top of file-backed config. cli_overrides: toml::Value, - /// Desktop-managed runtime proxy override read from private process env. - desktop_network_proxy_env: DesktopNetworkProxyEnv, -} - -/// Desktop-managed runtime network proxy override values. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct DesktopNetworkProxyEnv { - mode: Option, - proxy_url: Option, - no_proxy: Option, -} - -impl DesktopNetworkProxyEnv { - pub fn from_process_env() -> Self { - Self::from_env_fn(|key| std::env::var(key)) - } - - pub fn from_env_fn(env: F) -> Self - where - F: Fn(&str) -> Result, - { - Self { - mode: env(DESKTOP_NETWORK_PROXY_MODE_ENV) - .ok() - .and_then(|value| non_empty_string(&value)), - proxy_url: env(DESKTOP_NETWORK_PROXY_URL_ENV) - .ok() - .and_then(|value| non_empty_string(&value)), - no_proxy: env(DESKTOP_NETWORK_NO_PROXY_ENV) - .ok() - .and_then(|value| non_empty_string(&value)), - } - } - - pub fn custom(proxy_url: impl Into, no_proxy: Option<&str>) -> Self { - let proxy_url = proxy_url.into(); - Self { - mode: Some("custom".to_string()), - proxy_url: non_empty_string(&proxy_url), - no_proxy: no_proxy.and_then(non_empty_string), - } - } - - pub fn off() -> Self { - Self { - mode: Some("off".to_string()), - proxy_url: None, - no_proxy: None, - } - } } impl FileSystemAppConfigLoader { @@ -648,7 +594,6 @@ impl FileSystemAppConfigLoader { Self { config_folder_home, cli_overrides: toml::Value::Table(Default::default()), - desktop_network_proxy_env: DesktopNetworkProxyEnv::from_process_env(), } } @@ -658,11 +603,6 @@ impl FileSystemAppConfigLoader { self } - pub fn with_desktop_network_proxy_env(mut self, env: DesktopNetworkProxyEnv) -> Self { - self.desktop_network_proxy_env = env; - self - } - fn user_config_path(&self) -> PathBuf { self.config_folder_home.join(APP_CONFIG_FILE_NAME) } @@ -718,29 +658,11 @@ impl AppConfigLoader for FileSystemAppConfigLoader { message: source.to_string(), })?; config.provider = provider_config; - apply_desktop_network_proxy_env_override(&mut config, &self.desktop_network_proxy_env); validate_app_config(&config)?; Ok(config) } } -fn apply_desktop_network_proxy_env_override(config: &mut AppConfig, env: &DesktopNetworkProxyEnv) { - match env.mode.as_deref() { - Some("custom") => { - if let Some(proxy_url) = env.proxy_url.clone() { - config.provider_http.proxy_url = Some(proxy_url); - config.provider_http.no_proxy = env.no_proxy.clone(); - } - } - Some("off") => { - config.provider_http.proxy_url = None; - config.provider_http.no_proxy = None; - } - Some("system") | None => {} - Some(_) => {} - } -} - fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) { match (base, overlay) { (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => { diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 8a957ef2..ee269d1a 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -10,7 +10,6 @@ use super::AppConfig; use super::AppConfigLoader; use super::AppConfigStore; use super::CommandHookConfig; -use super::DesktopNetworkProxyEnv; use super::ExperimentalConfig; use super::FileSystemAppConfigLoader; use super::HookCommandConfig; @@ -476,71 +475,6 @@ invocation_method = "openai_responses" let _ = std::fs::remove_dir_all(root); } -/// Verifies: Desktop-managed runtime proxy env overrides file-backed provider proxy without mutating config files. -#[test] -fn loader_applies_desktop_network_proxy_custom_override() { - let root = unique_temp_dir("config-desktop-proxy-custom"); - let home = root.join("home").join(".devo"); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&home).expect("home config dir"); - std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); - let project_config = workspace.join(".devo").join("config.toml"); - std::fs::write( - &project_config, - r#" -[provider_http] -proxy_url = "http://workspace-proxy.example:8080" -"#, - ) - .expect("write project config"); - - let loader = FileSystemAppConfigLoader::new(home).with_desktop_network_proxy_env( - DesktopNetworkProxyEnv::custom("socks5h://127.0.0.1:7890", Some("localhost,127.0.0.1,::1")), - ); - let config = loader.load(Some(&workspace)).expect("load config"); - - assert_eq!( - config.provider_http, - ProviderHttpConfig { - proxy_url: Some("socks5h://127.0.0.1:7890".to_string()), - no_proxy: Some("localhost,127.0.0.1,::1".to_string()), - } - ); - let persisted = std::fs::read_to_string(project_config).expect("read project config"); - assert!(persisted.contains("workspace-proxy.example")); -} - -/// Verifies: Desktop-managed runtime proxy off mode clears the effective provider proxy only for the process. -#[test] -fn loader_applies_desktop_network_proxy_off_override() { - let root = unique_temp_dir("config-desktop-proxy-off"); - let home = root.join("home").join(".devo"); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&home).expect("home config dir"); - std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); - std::fs::write( - workspace.join(".devo").join("config.toml"), - r#" -[provider_http] -proxy_url = "http://workspace-proxy.example:8080" -no_proxy = "localhost" -"#, - ) - .expect("write project config"); - - let loader = FileSystemAppConfigLoader::new(home) - .with_desktop_network_proxy_env(DesktopNetworkProxyEnv::off()); - let config = loader.load(Some(&workspace)).expect("load config"); - - assert_eq!( - config.provider_http, - ProviderHttpConfig { - proxy_url: None, - no_proxy: None, - } - ); -} - /// Trace: L2-DES-APP-005 /// Verifies: omitted defaulted provider fields in a higher-priority partial overlay do not overwrite lower-priority values. #[test] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d9c2930d..62200e7d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -23,6 +23,7 @@ devo-safety = { workspace = true } devo-skills = { workspace = true } devo-tools = { workspace = true } devo-util-process = { workspace = true } +devo-util-paths = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index 58057a70..08bdab8b 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -3604,6 +3604,7 @@ mod tests { reasoning_effort: None, label: "Off".into(), description: "Use the standard model".into(), + extra_body: None, }, ReasoningVariant { selection_value: "enabled".into(), @@ -3611,6 +3612,7 @@ mod tests { reasoning_effort: Some(ReasoningEffort::Medium), label: "On".into(), description: "Use the reasoning model".into(), + extra_body: None, }, ], }, diff --git a/crates/core/src/skills.rs b/crates/core/src/skills.rs index 621d8e62..1dab9b11 100644 --- a/crates/core/src/skills.rs +++ b/crates/core/src/skills.rs @@ -1,5 +1,6 @@ //! Core-facing skill catalog wrapper backed by `devo-skills`. +use devo_util_paths; use std::path::Path; use std::path::PathBuf; @@ -104,8 +105,10 @@ pub struct FileSystemSkillCatalog { impl FileSystemSkillCatalog { pub fn new(config: SkillsConfig) -> Self { + let devo_home = devo_util_paths::find_devo_home() + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - Self::with_devo_home(config, cwd.clone(), cwd, vec![".git".to_string()]) + Self::with_devo_home(config, devo_home, cwd, vec![".git".to_string()]) } pub fn with_devo_home( diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index 7ef501dd..1494cd6f 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -472,7 +472,7 @@ impl Model { request_thinking: None, request_reasoning_effort: variant.reasoning_effort, effective_reasoning_effort: variant.reasoning_effort, - extra_body: None, + extra_body: variant.extra_body.clone(), } } else { ResolvedReasoningRequest { @@ -839,6 +839,7 @@ mod tests { reasoning_effort: None, label: String::from("Off"), description: String::from("Use the standard model"), + extra_body: None, }, ReasoningVariant { selection_value: String::from("enabled"), @@ -846,6 +847,7 @@ mod tests { reasoning_effort: Some(ReasoningEffort::Medium), label: String::from("On"), description: String::from("Use the reasoning model"), + extra_body: None, }, ], }, @@ -873,6 +875,7 @@ mod tests { reasoning_effort: None, label: String::from("Off"), description: String::from("Use the standard model"), + extra_body: None, }], }, )); diff --git a/crates/protocol/src/reasoning_effort.rs b/crates/protocol/src/reasoning_effort.rs index 39dcbda1..678cc8f5 100644 --- a/crates/protocol/src/reasoning_effort.rs +++ b/crates/protocol/src/reasoning_effort.rs @@ -86,6 +86,9 @@ pub struct ReasoningVariant { pub label: String, /// User-facing description shown alongside the label. pub description: String, + /// Optional provider-specific JSON merged into the request body. + #[serde(default)] + pub extra_body: Option, } /// Fully resolved request settings derived from a logical model plus reasoning-effort selection. diff --git a/crates/provider/src/anthropic/messages.rs b/crates/provider/src/anthropic/messages.rs index b9b17557..37031369 100644 --- a/crates/provider/src/anthropic/messages.rs +++ b/crates/provider/src/anthropic/messages.rs @@ -92,7 +92,9 @@ impl AnthropicProvider { .header(CONTENT_TYPE, HeaderValue::from_static("application/json")); let builder = if let Some(api_key) = &self.api_key { - builder.header("x-api-key", api_key) + builder + .header("x-api-key", api_key) + .header("Authorization", format!("Bearer {}", api_key)) } else { builder }; diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 39b8a1c9..776723ab 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -202,6 +202,7 @@ enum OnboardingStep { struct ActiveToolCall { tool_use_id: String, tool_name: Option, + seq: u64, input: Option, title: String, lines: Vec>, @@ -311,6 +312,15 @@ pub(crate) struct ChatWidget { user_cell_history_indices: Vec, startup_header_mascot_frame_index: usize, startup_header_next_animation_at: Instant, + next_seq: u64, +} + +impl ChatWidget { + fn reserve_seq(&mut self) -> u64 { + let seq = self.next_seq; + self.next_seq += 1; + seq + } } impl ChatWidget { @@ -464,6 +474,7 @@ impl ChatWidget { user_cell_history_indices: Vec::new(), startup_header_mascot_frame_index: 0, startup_header_next_animation_at: Instant::now() + STARTUP_HEADER_ANIMATION_INTERVAL, + next_seq: 0, }; // Model onboarding can inject additional startup UI before the first frame is drawn. diff --git a/crates/tui/src/chatwidget/restored_session.rs b/crates/tui/src/chatwidget/restored_session.rs index f61a7ecd..f0f882ea 100644 --- a/crates/tui/src/chatwidget/restored_session.rs +++ b/crates/tui/src/chatwidget/restored_session.rs @@ -252,7 +252,7 @@ impl ChatWidget { 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(), + Self::failed_dot_prefix(), Line::from(" "), Self::tool_text_style(), false, @@ -327,7 +327,7 @@ impl ChatWidget { title_line: (!call_item.title.is_empty()) .then(|| Self::ran_tool_line(&call_item.title)), dot_prefix: if result_item.kind == devo_protocol::SessionHistoryItemKind::Error { - self.failed_dot_prefix() + Self::failed_dot_prefix() } else { Self::tool_dot_prefix() }, diff --git a/crates/tui/src/chatwidget/session_header.rs b/crates/tui/src/chatwidget/session_header.rs index 406d6a7f..3e7e1d5a 100644 --- a/crates/tui/src/chatwidget/session_header.rs +++ b/crates/tui/src/chatwidget/session_header.rs @@ -22,6 +22,21 @@ use crate::startup_header::STARTUP_HEADER_ANIMATION_INTERVAL; use super::ChatWidget; use super::DotStatus; +/// Warm amber used for the "Thought" heading and the failed-tool indicator. +pub(super) const REASONING_ACCENT_COLOR: Color = Color::Rgb(210, 150, 60); +/// Green used for completed, idle, and done indicators. +pub(super) const COMPLETED_COLOR: Color = Color::Rgb(120, 220, 160); +/// Blue used for the pending-state dot prefix. +pub(super) const PENDING_DOT_COLOR: Color = Color::Rgb(110, 200, 255); +/// Blue used for running/active state text. +pub(super) const RUNNING_COLOR: Color = Color::Rgb(106, 200, 255); +/// Red used for failed/interrupted state. +pub(super) const FAILED_COLOR: Color = Color::Rgb(255, 100, 100); +/// Muted grey for secondary/unknown status text. +pub(super) const MUTED_COLOR: Color = Color::Rgb(160, 163, 168); +/// Subtle grey for preview text (only in subagent live list). +pub(super) const PREVIEW_COLOR: Color = Color::Rgb(176, 184, 196); + pub(super) struct SessionHeaderParams<'a> { pub cwd: &'a Path, pub model: Option<&'a Model>, @@ -88,23 +103,23 @@ impl ChatWidget { pub(super) fn completed_dot_prefix() -> Line<'static> { Line::from(vec![ - Span::styled("▌", Style::default().fg(Color::Rgb(120, 220, 160))), + Span::styled("▌", Style::default().fg(COMPLETED_COLOR)), " ".into(), ]) } pub(super) fn pending_dot_prefix() -> Line<'static> { Line::from(vec![ - Span::styled("▌", Style::default().fg(Color::Rgb(110, 200, 255))), + Span::styled("▌", Style::default().fg(PENDING_DOT_COLOR)), " ".into(), ]) } pub(super) fn reasoning_dot_prefix(status: DotStatus) -> Line<'static> { let color = match status { - DotStatus::Pending => Color::Rgb(210, 150, 60), - DotStatus::Completed => Color::Rgb(120, 220, 160), - DotStatus::Failed => Color::Rgb(255, 100, 100), + DotStatus::Pending => REASONING_ACCENT_COLOR, + DotStatus::Completed => COMPLETED_COLOR, + DotStatus::Failed => FAILED_COLOR, }; Line::from(vec![ Span::styled("▌", Style::default().fg(color)), @@ -148,15 +163,15 @@ impl ChatWidget { } pub(super) fn tool_text_style() -> Style { - Style::default().fg(Color::Rgb(160, 163, 168)) + Style::default().fg(MUTED_COLOR) } pub(super) fn tool_status_running_style() -> Style { - Style::default().fg(Color::Rgb(106, 200, 255)).bold() + Style::default().fg(RUNNING_COLOR).bold() } pub(super) fn tool_status_done_style() -> Style { - Style::default().fg(Color::Rgb(120, 220, 160)).bold() + Style::default().fg(COMPLETED_COLOR).bold() } pub(super) fn running_tool_line(title: &str) -> Line<'static> { @@ -195,15 +210,14 @@ impl ChatWidget { pub(super) fn tool_dot_prefix() -> Line<'static> { Line::from(vec![ - Span::styled("▌", Style::default().fg(Color::Rgb(120, 220, 160))), + Span::styled("▌", Style::default().fg(COMPLETED_COLOR)), " ".into(), ]) } - pub(super) fn failed_dot_prefix(&self) -> Line<'static> { - let error_color = self.active_error_color(); + pub(super) fn failed_dot_prefix() -> Line<'static> { Line::from(vec![ - Span::styled("▌", Style::default().fg(error_color)), + Span::styled("▌", Style::default().fg(REASONING_ACCENT_COLOR)), " ".into(), ]) } @@ -212,7 +226,7 @@ impl ChatWidget { match status { DotStatus::Pending => Self::pending_dot_prefix(), DotStatus::Completed => Self::completed_dot_prefix(), - DotStatus::Failed => self.failed_dot_prefix(), + DotStatus::Failed => Self::failed_dot_prefix(), } } @@ -496,7 +510,7 @@ impl ChatWidget { } pub(super) fn reasoning_heading_style() -> Style { - Style::default().italic().fg(Color::Rgb(210, 150, 60)) + Style::default().italic().fg(REASONING_ACCENT_COLOR) } pub(super) fn patch_lines_style(lines: &mut [Line<'static>], style: Style) { diff --git a/crates/tui/src/chatwidget/subagent_live_list.rs b/crates/tui/src/chatwidget/subagent_live_list.rs index 2cd9e46b..5b545bad 100644 --- a/crates/tui/src/chatwidget/subagent_live_list.rs +++ b/crates/tui/src/chatwidget/subagent_live_list.rs @@ -14,6 +14,11 @@ use ratatui::widgets::Widget; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; +use super::session_header::COMPLETED_COLOR; +use super::session_header::MUTED_COLOR; +use super::session_header::PREVIEW_COLOR; +use super::session_header::REASONING_ACCENT_COLOR; +use super::session_header::RUNNING_COLOR; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::ui_consts::LIVE_PREFIX_COLS; @@ -138,7 +143,7 @@ fn preview_line(row: &SubagentLiveListRow, max_width: usize) -> Line<'static> { Span::raw(prefix).dim(), Span::styled( truncate_preview_tail(&row.preview, preview_width), - Style::default().fg(Color::Rgb(176, 184, 196)), + Style::default().fg(PREVIEW_COLOR), ), ]) } @@ -172,17 +177,17 @@ fn take_suffix_by_width(text: &str, max_width: usize) -> String { fn status_marker_style(status: &str) -> Style { match status.to_ascii_lowercase().as_str() { - "idle" => Style::default().fg(Color::Rgb(120, 220, 160)).bold(), - "waiting_client" => Style::default().fg(Color::Rgb(210, 150, 60)).bold(), - _ => Style::default().fg(Color::Rgb(106, 200, 255)).bold(), + "idle" => Style::default().fg(COMPLETED_COLOR).bold(), + "waiting_client" => Style::default().fg(REASONING_ACCENT_COLOR).bold(), + _ => Style::default().fg(RUNNING_COLOR).bold(), } } fn status_text_style(status: &str) -> Style { match status.to_ascii_lowercase().as_str() { - "running" | "active_turn" => Style::default().fg(Color::Rgb(106, 200, 255)).bold(), - "idle" => Style::default().fg(Color::Rgb(120, 220, 160)).bold(), - "waiting_client" => Style::default().fg(Color::Rgb(210, 150, 60)).bold(), - _ => Style::default().fg(Color::Rgb(160, 163, 168)), + "running" | "active_turn" => Style::default().fg(RUNNING_COLOR).bold(), + "idle" => Style::default().fg(COMPLETED_COLOR).bold(), + "waiting_client" => Style::default().fg(REASONING_ACCENT_COLOR).bold(), + _ => Style::default().fg(MUTED_COLOR), } } diff --git a/crates/tui/src/chatwidget/subagent_monitor.rs b/crates/tui/src/chatwidget/subagent_monitor.rs index cbce71fe..ddf6c8b4 100644 --- a/crates/tui/src/chatwidget/subagent_monitor.rs +++ b/crates/tui/src/chatwidget/subagent_monitor.rs @@ -525,7 +525,7 @@ impl ChatWidget { } MonitorTranscriptKind::Plan | MonitorTranscriptKind::Status => { let dot_prefix = if item.is_error { - self.failed_dot_prefix() + Self::failed_dot_prefix() } else { Self::completed_dot_prefix() }; diff --git a/crates/tui/src/chatwidget/subagent_selector.rs b/crates/tui/src/chatwidget/subagent_selector.rs index df713f53..29803e73 100644 --- a/crates/tui/src/chatwidget/subagent_selector.rs +++ b/crates/tui/src/chatwidget/subagent_selector.rs @@ -13,6 +13,11 @@ use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Clear; use ratatui::widgets::Widget; +use super::session_header::COMPLETED_COLOR; +use super::session_header::FAILED_COLOR; +use super::session_header::MUTED_COLOR; +use super::session_header::REASONING_ACCENT_COLOR; +use super::session_header::RUNNING_COLOR; pub(super) struct SubagentSelectorAgent<'a> { pub(super) session_id: SessionId, @@ -228,12 +233,12 @@ fn status_badge(status: &str) -> Span<'static> { fn status_style(status: &str) -> Style { match status.to_ascii_lowercase().as_str() { - "running" | "active_turn" => Style::default().fg(Color::Rgb(106, 200, 255)).bold(), - "idle" => Style::default().fg(Color::Rgb(120, 220, 160)).bold(), - "waiting_client" => Style::default().fg(Color::Rgb(210, 150, 60)).bold(), - "completed" => Style::default().fg(Color::Rgb(120, 220, 160)), - "failed" | "interrupted" => Style::default().fg(Color::Rgb(255, 100, 100)).bold(), + "running" | "active_turn" => Style::default().fg(RUNNING_COLOR).bold(), + "idle" => Style::default().fg(COMPLETED_COLOR).bold(), + "waiting_client" => Style::default().fg(REASONING_ACCENT_COLOR).bold(), + "completed" => Style::default().fg(COMPLETED_COLOR), + "failed" | "interrupted" => Style::default().fg(FAILED_COLOR).bold(), "closed" => Style::default().dim(), - _ => Style::default().fg(Color::Rgb(160, 163, 168)), + _ => Style::default().fg(MUTED_COLOR), } } diff --git a/crates/tui/src/chatwidget/text_stream.rs b/crates/tui/src/chatwidget/text_stream.rs index 69827fec..861da952 100644 --- a/crates/tui/src/chatwidget/text_stream.rs +++ b/crates/tui/src/chatwidget/text_stream.rs @@ -26,6 +26,7 @@ use super::ResearchTaskPreview; pub(super) struct ActiveTextItem { pub(super) item_id: ActiveTextItemId, pub(super) kind: TextItemKind, + pub(super) seq: u64, pub(super) status: DotStatus, pub(super) stream_controller: Option, last_renderable_delta_at: Option, @@ -112,6 +113,7 @@ impl ChatWidget { return; } + let seq = self.reserve_seq(); let stream_controller = match kind { TextItemKind::Assistant => Some(StreamController::new(None, &self.session.cwd)), TextItemKind::Reasoning | TextItemKind::ResearchArtifact => None, @@ -129,6 +131,7 @@ impl ChatWidget { ActiveTextItem { item_id, kind, + seq, status: DotStatus::Pending, stream_controller, last_renderable_delta_at: None, diff --git a/crates/tui/src/chatwidget/transcript_view.rs b/crates/tui/src/chatwidget/transcript_view.rs index 7ceabfe7..242888d7 100644 --- a/crates/tui/src/chatwidget/transcript_view.rs +++ b/crates/tui/src/chatwidget/transcript_view.rs @@ -196,40 +196,62 @@ impl ChatWidget { if let Some(cell) = &self.active_cell { Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width)); } - for item in &self.active_text_items { - if let Some(cell) = &item.cell { - Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width)); + + // Build a merged list of (seq, LiveItem) sorted by seq + #[allow(clippy::large_enum_variant)] + enum LiveItem { + Text(usize), + Tool(String), + } + let mut items: Vec<(u64, LiveItem)> = Vec::new(); + for (idx, item) in self.active_text_items.iter().enumerate() { + if item.cell.is_some() { + items.push((item.seq, LiveItem::Text(idx))); } } - let mut tool_calls = self.active_tool_calls.values().collect::>(); - tool_calls.sort_by(|left, right| left.tool_use_id.cmp(&right.tool_use_id)); - for tool_call in tool_calls { + for tool_call in self.active_tool_calls.values() { if tool_call.exec_like { continue; } - let transcript_lines = match (&tool_call.tool_name, &tool_call.input) { - (Some(tool_name), Some(input)) => ToolIoCell::from_text_output( - ToolIoCellOptions { - title_line: Some(Self::running_tool_line(&tool_call.title)), - dot_prefix: Self::pending_dot_prefix(), - subsequent_prefix: " ".into(), - output_style: Self::tool_text_style(), - show_empty_ellipsis: false, - }, - tool_name.clone(), - input.clone(), - tool_call.output.clone(), - ) - .transcript_lines(width), - _ => history_cell::AgentMessageCell::new_with_prefix( - tool_call.lines.clone(), - Self::pending_dot_prefix(), - " ", - false, - ) - .transcript_lines(width), - }; - Self::extend_lines_with_separator(&mut lines, transcript_lines); + items.push((tool_call.seq, LiveItem::Tool(tool_call.tool_use_id.clone()))); + } + items.sort_by(|a, b| a.0.cmp(&b.0)); + + for (_, item) in items { + match item { + LiveItem::Text(idx) => { + if let Some(cell) = &self.active_text_items[idx].cell { + Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width)); + } + } + LiveItem::Tool(tool_use_id) => { + if let Some(tool_call) = self.active_tool_calls.get(&tool_use_id) { + let transcript_lines = match (&tool_call.tool_name, &tool_call.input) { + (Some(tool_name), Some(input)) => ToolIoCell::from_text_output( + ToolIoCellOptions { + title_line: Some(Self::running_tool_line(&tool_call.title)), + dot_prefix: Self::pending_dot_prefix(), + subsequent_prefix: " ".into(), + output_style: Self::tool_text_style(), + show_empty_ellipsis: false, + }, + tool_name.clone(), + input.clone(), + tool_call.output.clone(), + ) + .transcript_lines(width), + _ => history_cell::AgentMessageCell::new_with_prefix( + tool_call.lines.clone(), + Self::pending_dot_prefix(), + " ", + false, + ) + .transcript_lines(width), + }; + Self::extend_lines_with_separator(&mut lines, transcript_lines); + } + } + } } for pending in &self.pending_tool_calls { let pending_lines = if let Some(start_time) = pending.start_time { diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index 7d041940..c6f65ac1 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -65,10 +65,12 @@ impl ChatWidget { grouped.set_tool_io_input(&tool_use_id, "exec_command".to_string(), input); } *cell = grouped; + let seq = self.reserve_seq(); self.active_tool_calls.insert( tool_use_id.clone(), ActiveToolCall { tool_use_id, + seq, tool_name: Some("exec_command".to_string()), input: input.clone(), title, @@ -91,10 +93,12 @@ impl ChatWidget { cell.set_tool_io_input(&tool_use_id, "exec_command".to_string(), input); } self.active_cell = Some(Box::new(cell)); + let seq = self.reserve_seq(); self.active_tool_calls.insert( tool_use_id.clone(), ActiveToolCall { tool_use_id, + seq, tool_name: Some("exec_command".to_string()), input, title, @@ -291,8 +295,10 @@ impl ChatWidget { } else { summary }; + let seq = self.reserve_seq(); let tool_call = ActiveToolCall { tool_use_id: tool_use_id.clone(), + seq, tool_name: None, input: None, title: title.clone(), @@ -316,8 +322,10 @@ impl ChatWidget { } else { format!("Running {title}") }; + let seq = self.reserve_seq(); self.pending_tool_calls.push(ActiveToolCall { tool_use_id, + seq, tool_name: None, input: None, title: pending_title, @@ -471,11 +479,13 @@ impl ChatWidget { } else { DotStatus::Completed }; + let seq = self.reserve_seq(); let resolved_tool_call = self.active_tool_calls .remove(&tool_use_id) .unwrap_or(ActiveToolCall { tool_use_id: tool_use_id.clone(), + seq, tool_name: Some(tool_name.clone()), input: Some(input.clone()), title, @@ -605,11 +615,13 @@ impl ChatWidget { } else { DotStatus::Completed }; + let seq = self.reserve_seq(); let resolved_title = self.active_tool_calls .remove(&tool_use_id) .unwrap_or(ActiveToolCall { tool_use_id: tool_use_id.clone(), + seq, tool_name: None, input: None, title, diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 4c6e71ee..86d0c96b 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -2653,7 +2653,7 @@ async fn run_worker_inner( tool_use_id: process_id, title: "Shell".to_string(), preview: String::new(), - is_error: exit_code != Some(0), + is_error: false, truncated: false, }); let _ = event_tx.send(WorkerEvent::ShellCommandFinished {