diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 5d93886f..a8ac7831 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1185,6 +1185,10 @@ pub struct App { /// Ghost-text follow-up suggestion shown in the composer when empty. /// Generated asynchronously after each completed turn; cleared on new input. pub prompt_suggestion: Option, + /// Monotonic turn counter for stale-suggestion protection. Incremented on + /// each TurnStarted; background suggestion tasks capture the token and + /// discard their result if the token no longer matches. + pub prompt_suggestion_gen: std::sync::atomic::AtomicU64, /// Degraded connectivity mode; new user inputs are queued for later retry. pub offline_mode: bool, /// Whether an `EngineEvent::Error` has already been posted for the @@ -1525,7 +1529,7 @@ pub struct App { /// Shared cell updated by background fetch tasks; read lock in the UI thread. pub balance_cell: std::sync::Arc>>, /// Shared cell for async prompt suggestion delivery from background task. - pub prompt_suggestion_cell: std::sync::Arc>>, + pub prompt_suggestion_cell: std::sync::Arc>>, /// Tracks whether the initial balance fetch has been attempted for this session. pub balance_initiated: bool, /// Timestamp of the last balance fetch, used to debounce rapid requests. @@ -1997,6 +2001,7 @@ impl App { api_messages: Vec::new(), is_loading: false, prompt_suggestion: None, + prompt_suggestion_gen: std::sync::atomic::AtomicU64::new(0), offline_mode: false, turn_error_posted: false, status_message: None, diff --git a/crates/tui/src/tui/prompt_suggestion.rs b/crates/tui/src/tui/prompt_suggestion.rs index 484d5301..0e2be2b8 100644 --- a/crates/tui/src/tui/prompt_suggestion.rs +++ b/crates/tui/src/tui/prompt_suggestion.rs @@ -4,10 +4,18 @@ //! follow-up question the user might want to ask next. The suggestion is //! rendered as dimmed ghost text in the composer when the input is empty. +use std::sync::OnceLock; + use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; use serde_json::Value; use tracing::debug; +/// Reusable static client — avoids creating a new connection pool per request. +fn suggestion_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(reqwest::Client::new) +} + /// Generate a follow-up prompt suggestion based on recent messages. /// /// Sends the conversation summary to the API with a system prompt that @@ -19,7 +27,7 @@ pub async fn generate_suggestion( model: &str, recent_messages: &str, ) -> Option { - let client = reqwest::Client::new(); + let client = suggestion_client(); let body = serde_json::json!({ "model": model, "messages": [ diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 936bd462..24bdd9ad 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1115,6 +1115,7 @@ async fn run_event_loop( // codex's frame coalescing that maps cleanly onto our poll-based loop. let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default(); let mut web_config_session: Option = None; + let mut prev_input_snapshot = String::new(); let mut terminal_paused_at: Option = None; let mut force_terminal_repaint = false; let mut draws_since_last_full_repaint: u64 = 0; @@ -1265,11 +1266,22 @@ async fn run_event_loop( app.needs_redraw = true; } + // Clear suggestion when the user modifies the input. + if app.input != prev_input_snapshot { + app.prompt_suggestion = None; + prev_input_snapshot = app.input.clone(); + } + // Poll prompt suggestion cell from background generation task. - if let Ok(mut guard) = app.prompt_suggestion_cell.lock() { - if let Some(suggestion) = guard.take() { - app.prompt_suggestion = Some(suggestion); - } + // Discard stale results whose generation token no longer matches. + if let Ok(mut guard) = app.prompt_suggestion_cell.try_lock() + && let Some((gen_token, suggestion)) = guard.take() + && gen_token + == app + .prompt_suggestion_gen + .load(std::sync::atomic::Ordering::Relaxed) + { + app.prompt_suggestion = Some(suggestion); } // First, poll for engine events (non-blocking) @@ -1626,6 +1638,8 @@ async fn run_event_loop( app.offline_mode = false; app.turn_error_posted = false; app.prompt_suggestion = None; + app.prompt_suggestion_gen + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); app.dispatch_started_at = None; current_streaming_text.clear(); app.streaming_state.reset(); @@ -1834,9 +1848,10 @@ async fn run_event_loop( let suggestion_cell = app.prompt_suggestion_cell.clone(); let api_key = config.deepseek_api_key().unwrap_or_default(); let base_url = config.deepseek_base_url(); - let model = config.default_model(); - let messages: Vec = - app.api_messages.clone(); + let messages: Vec = app.api_messages.clone(); + let gen_token = app + .prompt_suggestion_gen + .load(std::sync::atomic::Ordering::Relaxed); if !api_key.is_empty() { tokio::spawn(async move { let summary = @@ -1847,13 +1862,13 @@ async fn run_event_loop( crate::tui::prompt_suggestion::generate_suggestion( &api_key, &base_url, - &model, + "deepseek-v4-flash", &summary, ) .await && let Ok(mut guard) = suggestion_cell.lock() { - *guard = Some(suggestion); + *guard = Some((gen_token, suggestion)); } }); } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 7339d15f..58f63525 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -659,7 +659,9 @@ impl Renderable for ComposerWidget<'_> { let mut input_lines = Vec::new(); if input_text.is_empty() { - if let Some(ref suggestion) = self.app.prompt_suggestion { + if let Some(ref suggestion) = self.app.prompt_suggestion + && !self.app.is_history_search_active() + { input_lines.push(Line::from(Span::styled( suggestion.as_str(), Style::default().fg(palette::TEXT_HINT), @@ -711,16 +713,14 @@ impl Renderable for ComposerWidget<'_> { // wrap the single Line at render time, so we must estimate the wrapped // row count ourselves to keep padding accurate on narrow widths. let visual_rows = if input_text.is_empty() { - let placeholder = if let Some(ref suggestion) = self.app.prompt_suggestion { + let placeholder: &str = if let Some(ref suggestion) = self.app.prompt_suggestion { suggestion.as_str() } else if self.app.is_history_search_active() { self.app .tr(crate::localization::MessageId::HistorySearchPlaceholder) - .as_ref() } else { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) - .as_ref() }; placeholder_visual_lines_for(placeholder, content_width) } else { @@ -1020,16 +1020,14 @@ impl Renderable for ComposerWidget<'_> { let (visible_lines, cursor_row, cursor_col) = layout_input(input_text, input_cursor, content_width, input_rows_budget); let visual_rows = if input_text.is_empty() { - let placeholder = if let Some(ref suggestion) = self.app.prompt_suggestion { + let placeholder: &str = if let Some(ref suggestion) = self.app.prompt_suggestion { suggestion.as_str() } else if self.app.is_history_search_active() { self.app .tr(crate::localization::MessageId::HistorySearchPlaceholder) - .as_ref() } else { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) - .as_ref() }; placeholder_visual_lines_for(placeholder, content_width) } else {