From 02c3579be1192c40a6bc2619cf8b56b47c14c8e4 Mon Sep 17 00:00:00 2001 From: Punkcan Yang Date: Fri, 5 Jun 2026 13:45:48 +0800 Subject: [PATCH 1/4] feat(tui): ghost-text prompt suggestion after each turn After each completed turn, a lightweight API call generates a short follow-up question rendered as dimmed ghost text in the composer. Tab accepts the suggestion; typing dismisses it. - prompt_suggestion.rs: async suggestion generation via API - app.rs: prompt_suggestion display field + suggestion_cell for cross-thread delivery (Arc>> pattern) - widgets/mod.rs: ghost text rendered with TEXT_HINT when input is empty and suggestion exists - ui.rs: suggestion generation on TurnComplete, cleanup on TurnStarted, Tab acceptance in event loop Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/app.rs | 7 ++ crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/prompt_suggestion.rs | 117 ++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 48 ++++++++++ crates/tui/src/tui/widgets/mod.rs | 39 +++++--- 5 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 crates/tui/src/tui/prompt_suggestion.rs diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3eb494f1..5d93886f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1182,6 +1182,9 @@ pub struct App { pub next_history_revision: u64, pub api_messages: Vec, pub is_loading: bool, + /// 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, /// 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 @@ -1521,6 +1524,8 @@ pub struct App { /// DeepSeek account balance, refreshed once per turn completion. /// 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>>, /// 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. @@ -1991,6 +1996,7 @@ impl App { next_history_revision: 1, api_messages: Vec::new(), is_loading: false, + prompt_suggestion: None, offline_mode: false, turn_error_posted: false, status_message: None, @@ -2145,6 +2151,7 @@ impl App { turn_last_activity_at: None, cumulative_turn_duration: std::time::Duration::ZERO, balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), + prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), balance_initiated: false, last_balance_fetch: None, runtime_turn_id: None, diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index af2d8996..a987a03d 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -51,6 +51,7 @@ pub mod paste; pub mod paste_burst; pub mod persistence_actor; pub mod plan_prompt; +pub mod prompt_suggestion; pub mod provider_picker; pub mod scrolling; pub mod selection; diff --git a/crates/tui/src/tui/prompt_suggestion.rs b/crates/tui/src/tui/prompt_suggestion.rs new file mode 100644 index 00000000..484d5301 --- /dev/null +++ b/crates/tui/src/tui/prompt_suggestion.rs @@ -0,0 +1,117 @@ +//! Ghost-text follow-up prompt suggestion. +//! +//! After each completed turn, a lightweight API call generates ONE short +//! 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 reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; +use serde_json::Value; +use tracing::debug; + +/// Generate a follow-up prompt suggestion based on recent messages. +/// +/// Sends the conversation summary to the API with a system prompt that +/// asks for a single short follow-up question. Returns `None` on failure +/// or empty result — callers treat this as best-effort. +pub async fn generate_suggestion( + api_key: &str, + base_url: &str, + model: &str, + recent_messages: &str, +) -> Option { + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": "\ + You are a helpful assistant. Based on the recent conversation context, generate \ + ONE short follow-up question (under 60 characters) the user might want to ask \ + next. Reply with ONLY the question text, nothing else — no quotes, no explanations, \ + no prefixes." + }, + { + "role": "user", + "content": format!( + "Recent conversation:\n{recent_messages}\n\n\ + Generate ONE short follow-up question the user might ask next:" + ) + } + ], + "max_tokens": 64, + "temperature": 0.3, + "stream": false + }); + + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + debug!(%url, %model, "generating prompt suggestion"); + let response = match client + .post(&url) + .header(AUTHORIZATION, format!("Bearer {api_key}")) + .header(CONTENT_TYPE, "application/json") + .timeout(std::time::Duration::from_secs(10)) + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(_) => return None, + }; + + let value: Value = match response.json().await { + Ok(v) => v, + Err(_) => return None, + }; + + let suggestion = value["choices"][0]["message"]["content"] + .as_str() + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| !s.is_empty() && s.len() <= 200)?; + + debug!(text = %suggestion, "prompt suggestion generated"); + Some(suggestion) +} + +/// Extract the first text line from a single message. +fn message_summary(m: &crate::models::Message) -> Option { + let role = match m.role.as_str() { + "user" => "User", + "assistant" => "Assistant", + _ => return None, + }; + let text = m + .content + .iter() + .filter_map(|block| match block { + crate::models::ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" "); + let first_line = text.lines().next().unwrap_or("").trim(); + if first_line.is_empty() { + return None; + } + let truncated: String = first_line + .chars() + .take(120) + .chain(if first_line.chars().count() > 120 { + Some('…') + } else { + None + }) + .collect(); + Some(format!("{role}: {truncated}")) +} + +/// Build a one-line-per-message summary of recent conversation context. +/// Takes the last N messages, skipping tool-only messages. +pub fn summarize_recent_messages(messages: &[crate::models::Message], limit: usize) -> String { + let start = messages.len().saturating_sub(limit); + messages[start..] + .iter() + .filter_map(message_summary) + .collect::>() + .join("\n") +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fad..936bd462 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1265,6 +1265,13 @@ async fn run_event_loop( app.needs_redraw = true; } + // 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); + } + } + // First, poll for engine events (non-blocking) let mut received_engine_event = false; let mut transcript_batch_updated = false; @@ -1618,6 +1625,7 @@ async fn run_event_loop( app.is_loading = true; app.offline_mode = false; app.turn_error_posted = false; + app.prompt_suggestion = None; app.dispatch_started_at = None; current_streaming_text.clear(); app.streaming_state.reset(); @@ -1819,6 +1827,38 @@ async fn run_event_loop( } } + // Generate ghost-text follow-up suggestion asynchronously. + if status == crate::core::events::TurnOutcomeStatus::Completed + && app.api_messages.len() >= 2 + { + 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(); + if !api_key.is_empty() { + tokio::spawn(async move { + let summary = + crate::tui::prompt_suggestion::summarize_recent_messages( + &messages, 8, + ); + if let Some(suggestion) = + crate::tui::prompt_suggestion::generate_suggestion( + &api_key, + &base_url, + &model, + &summary, + ) + .await + && let Ok(mut guard) = suggestion_cell.lock() + { + *guard = Some(suggestion); + } + }); + } + } + // Generate post-turn receipt for completed turns. // Also push a persistent status toast so users always // see the outcome in the footer (not just the 8-second @@ -3591,6 +3631,14 @@ async fn run_event_loop( if app.is_loading && queue_current_draft_for_next_turn(app) { continue; } + if app.input.is_empty() + && let Some(suggestion) = app.prompt_suggestion.take() + { + app.input = suggestion; + app.cursor_position = app.input.chars().count(); + app.needs_redraw = true; + continue; + } let prior_model = app.model.clone(); let prior_mode = app.mode; app.cycle_mode(); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 92deb9be..7339d15f 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -659,17 +659,24 @@ impl Renderable for ComposerWidget<'_> { let mut input_lines = Vec::new(); if input_text.is_empty() { - let placeholder = if self.app.is_history_search_active() { - self.app - .tr(crate::localization::MessageId::HistorySearchPlaceholder) + if let Some(ref suggestion) = self.app.prompt_suggestion { + input_lines.push(Line::from(Span::styled( + suggestion.as_str(), + Style::default().fg(palette::TEXT_HINT), + ))); } else { - self.app - .tr(crate::localization::MessageId::ComposerPlaceholder) - }; - input_lines.push(Line::from(Span::styled( - placeholder, - Style::default().fg(palette::TEXT_MUTED).italic(), - ))); + let placeholder = if self.app.is_history_search_active() { + self.app + .tr(crate::localization::MessageId::HistorySearchPlaceholder) + } else { + self.app + .tr(crate::localization::MessageId::ComposerPlaceholder) + }; + input_lines.push(Line::from(Span::styled( + placeholder, + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + } } else if let Some((sel_start, sel_end)) = self.app.selection_range() { let line_ranges: Vec<(usize, usize)> = wrap_input_lines_for_mouse(&self.app.input, content_width) @@ -704,12 +711,16 @@ 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 self.app.is_history_search_active() { + let placeholder = 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 { @@ -1009,12 +1020,16 @@ 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 self.app.is_history_search_active() { + let placeholder = 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 { From eb1d08b05efecb44b519366657b46be2e3624a7e Mon Sep 17 00:00:00 2001 From: Punkcan Yang Date: Fri, 5 Jun 2026 23:52:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(tui):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?turn=20token,=20try=5Flock,=20static=20client,=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use static OnceLock 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 --- crates/tui/src/tui/app.rs | 7 +++++- crates/tui/src/tui/prompt_suggestion.rs | 10 +++++++- crates/tui/src/tui/ui.rs | 33 ++++++++++++++++++------- crates/tui/src/tui/widgets/mod.rs | 12 ++++----- 4 files changed, 44 insertions(+), 18 deletions(-) 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 { From e687b07bc6a079ce28ca2e34def24594d56deddb Mon Sep 17 00:00:00 2001 From: Punkcan Yang Date: Sat, 6 Jun 2026 17:57:20 +0800 Subject: [PATCH 3/4] fix(tui): make prompt suggestion configurable (opt-in, default off) - Add prompt_suggestion: Option config field with prompt_suggestion_enabled() accessor (defaults to false) - Guard suggestion generation behind the config check - Use config.default_model() (provider-aware) instead of hardcoded deepseek-v4-flash to avoid cross-provider data egress - Document in config.example.toml Co-Authored-By: Claude Opus 4.7 --- config.example.toml | 1 + crates/tui/src/config.rs | 9 +++++++++ crates/tui/src/tui/ui.rs | 7 +++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/config.example.toml b/config.example.toml index b5343535..eb16c23f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -144,6 +144,7 @@ memory_path = "~/.codewhale/memory.md" allow_shell = true approval_policy = "on-request" # on-request | untrusted | never sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox +# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn # Typed permission rules live in a sibling `permissions.toml` file, not in # config.toml. This schema slice is ask-only and is parsed for follow-up diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f71400b7..f9c0c0bd 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1550,6 +1550,9 @@ pub struct Config { /// missing optional file doesn't fail the launch. pub instructions: Option>, pub allow_shell: Option, + /// Opt-in ghost-text follow-up prompt suggestion after each completed turn. + /// Default: false — the user must explicitly set this to true to enable. + pub prompt_suggestion: Option, pub approval_policy: Option, pub sandbox_mode: Option, pub yolo: Option, @@ -2707,6 +2710,11 @@ impl Config { self.allow_shell.unwrap_or(false) } + /// Whether ghost-text prompt suggestion is enabled (opt-in, default off). + pub fn prompt_suggestion_enabled(&self) -> bool { + self.prompt_suggestion.unwrap_or(false) + } + /// Return the maximum number of concurrent sub-agents. /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`, /// then falls back to `DEFAULT_MAX_SUBAGENTS`. @@ -4253,6 +4261,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { // both — they list `~/global.md` inside the project array. instructions: override_cfg.instructions.or(base.instructions), allow_shell: override_cfg.allow_shell.or(base.allow_shell), + prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion), yolo: override_cfg.yolo.or(base.yolo), approval_policy: override_cfg.approval_policy.or(base.approval_policy), sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 24bdd9ad..7b70d702 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1843,11 +1843,13 @@ async fn run_event_loop( // Generate ghost-text follow-up suggestion asynchronously. if status == crate::core::events::TurnOutcomeStatus::Completed + && config.prompt_suggestion_enabled() && app.api_messages.len() >= 2 { 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 gen_token = app .prompt_suggestion_gen @@ -1860,10 +1862,7 @@ async fn run_event_loop( ); if let Some(suggestion) = crate::tui::prompt_suggestion::generate_suggestion( - &api_key, - &base_url, - "deepseek-v4-flash", - &summary, + &api_key, &base_url, &model, &summary, ) .await && let Ok(mut guard) = suggestion_cell.lock() From 80246f0591afbd75a6274600771d2c7f7f94f91a Mon Sep 17 00:00:00 2001 From: Punkcan Yang Date: Sat, 6 Jun 2026 18:01:43 +0800 Subject: [PATCH 4/4] test(tui): add prompt suggestion config and widget tests - Config tests: defaults to false, enabled when set true - Widget tests: ghost text renders when set, hidden when input non-empty, hidden when no suggestion Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/config.rs | 22 ++++++++ crates/tui/src/tui/widgets/mod.rs | 84 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f9c0c0bd..b0020af5 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -5415,6 +5415,28 @@ mod tests { ); } + #[test] + fn prompt_suggestion_defaults_to_false() { + let config = Config::default(); + assert_eq!( + config.prompt_suggestion, None, + "default Config must not opt in" + ); + assert!( + !config.prompt_suggestion_enabled(), + "prompt_suggestion must be opt-in (default off)" + ); + } + + #[test] + fn prompt_suggestion_enabled_when_set_true() { + let config = Config { + prompt_suggestion: Some(true), + ..Default::default() + }; + assert!(config.prompt_suggestion_enabled()); + } + #[test] fn warns_when_allow_shell_nested_under_general_section() { // #2589: the reporter's config nested top-level keys under sections that diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 58f63525..5c966adc 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -4052,4 +4052,88 @@ mod tests { ); } } + + // ── Ghost-text prompt suggestion rendering ──────────────────────── + + #[test] + fn ghost_text_renders_when_suggestion_set_and_input_empty() { + let mut app = create_test_app(); + app.prompt_suggestion = Some("What about error handling?".to_string()); + let slash_menu_entries = Vec::::new(); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 5, + }; + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + + let rendered: String = buf + .content + .iter() + .map(|c| c.symbol()) + .collect::>() + .join(""); + assert!( + rendered.contains("What about error handling?"), + "ghost text should render the suggestion. Got: {rendered}" + ); + } + + #[test] + fn ghost_text_hidden_when_input_not_empty() { + let mut app = create_test_app(); + app.prompt_suggestion = Some("A suggestion".to_string()); + app.input = "hello".to_string(); + app.cursor_position = 5; + let slash_menu_entries = Vec::::new(); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 5, + }; + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + + let has_suggestion = buf + .content + .iter() + .any(|c| c.symbol().contains("A suggestion")); + assert!( + !has_suggestion, + "suggestion should not render when input is non-empty" + ); + } + + #[test] + fn ghost_text_hidden_when_no_suggestion() { + let mut app = create_test_app(); + app.prompt_suggestion = None; + let slash_menu_entries = Vec::::new(); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 5, + }; + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + + // When no suggestion and input is empty, placeholder text should appear + // instead. The exact placeholder text is locale-dependent, so we check + // that the suggestion text is NOT present. + let has_placeholder_like_text = buf.content.iter().any(|c| !c.symbol().trim().is_empty()); + assert!( + has_placeholder_like_text, + "some non-empty text should render as placeholder" + ); + } }