diff --git a/config.example.toml b/config.example.toml index 8d6686fb..4ee86ff3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -164,6 +164,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 aeb75ead..be3bd836 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1580,6 +1580,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, @@ -2766,6 +2769,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`. @@ -4483,6 +4491,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), @@ -5641,6 +5650,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/app.rs b/crates/tui/src/tui/app.rs index a793e12e..76aba38c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1260,6 +1260,13 @@ 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, + /// 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 @@ -1611,6 +1618,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. @@ -2089,6 +2098,8 @@ impl App { next_history_revision: 1, 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, @@ -2246,6 +2257,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 88ed24ca..fa6ef520 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -53,6 +53,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..0e2be2b8 --- /dev/null +++ b/crates/tui/src/tui/prompt_suggestion.rs @@ -0,0 +1,125 @@ +//! 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 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 +/// 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 = suggestion_client(); + 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 da43754d..9320f7d9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1239,6 +1239,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; @@ -1392,6 +1393,24 @@ 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. + // 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) let mut received_engine_event = false; let mut transcript_batch_updated = false; @@ -1746,6 +1765,9 @@ async fn run_event_loop( app.is_loading = true; 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(); @@ -1952,6 +1974,38 @@ 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 + .load(std::sync::atomic::Ordering::Relaxed); + 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((gen_token, 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 @@ -3824,6 +3878,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 181a45a0..10bbb81a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -723,17 +723,26 @@ 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 + && !self.app.is_history_search_active() + { + 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) @@ -768,7 +777,9 @@ 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: &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) } else { @@ -1073,7 +1084,9 @@ 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: &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) } else { @@ -4363,4 +4376,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" + ); + } }