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<Mutex<Option<String>>> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1182,6 +1182,9 @@ pub struct App {
|
||||
pub next_history_revision: u64,
|
||||
pub api_messages: Vec<Message>,
|
||||
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<String>,
|
||||
/// 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<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>>>,
|
||||
/// 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -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<crate::models::Message> =
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user