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:
Punkcan Yang
2026-06-05 23:52:35 +08:00
parent 02c3579be1
commit eb1d08b05e
4 changed files with 44 additions and 18 deletions
+6 -1
View File
@@ -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,
+9 -1
View File
@@ -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": [
+24 -9
View File
@@ -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));
}
});
}
+5 -7
View File
@@ -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 {