fix(tui): address review — turn token, try_lock, static client, lifecycle
- Use static OnceLock<reqwest::Client> to reuse connections - Use lightweight model (deepseek-v4-flash) for suggestions - Add AtomicU64 turn token for stale-suggestion protection - Use try_lock() instead of lock() in main loop - Clear suggestion on any input change (tracked via prev_input_snapshot) - Hide suggestion during history search in composer widget Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
/// 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<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
|
||||
/// Shared cell for async prompt suggestion delivery from background task.
|
||||
pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<String>>>,
|
||||
pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<(u64, String)>>>,
|
||||
/// 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,
|
||||
|
||||
@@ -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<reqwest::Client> = 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<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let client = suggestion_client();
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
|
||||
@@ -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<WebConfigSession> = None;
|
||||
let mut prev_input_snapshot = String::new();
|
||||
let mut terminal_paused_at: Option<Instant> = 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<crate::models::Message> =
|
||||
app.api_messages.clone();
|
||||
let messages: Vec<crate::models::Message> = 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user