Suggest conversation-based thread titles in /rename (#40495)

## What changed

- Generate a title suggestion from the latest substantive user and assistant messages when the TUI `/rename` prompt opens.
- Prefill the prompt with the result while keeping it editable and preserving any text the user enters before generation finishes.
- Bound and escape the conversation context, omit commentary and IDE context, and correlate responses with the active thread and prompt so stale suggestions are ignored.

## Testing

- Cover conversation selection and prompt construction, generated-title delivery through the embedded app server, loading and prefilled prompt states, stale responses, and preservation of manual edits.

GitOrigin-RevId: 1e7f1d5d4f6503ff9da0548388f610c446c130ca
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-08-24 23:15:04 +00:00
1 parent 0c992c305d
commit 5a51caf04d
13 files changed
+877 -2

No files matched your search

@@ -56,6 +56,15 @@ pub(crate) trait BottomPaneView: Renderable {
None
}
/// Apply a matching background suggestion when this view supports text prefills.
fn apply_text_suggestion(
&mut self,
_request_id: uuid::Uuid,
_suggestion: Option<&str>,
) -> bool {
false
}
/// Active tab id for tabbed list-based views.
#[allow(dead_code)]
fn active_tab_id(&self) -> Option<&str> {
@@ -12,6 +12,7 @@ use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::cell::RefCell;
use std::time::Instant;
use uuid::Uuid;
use crate::key_hint;
use crate::key_hint::has_ctrl_or_alt;
@@ -35,6 +36,12 @@ use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
/// Correlates a pending generated prefill with its eventual display label.
struct PendingTextSuggestion {
request_id: Uuid,
applied_label: String,
}
/// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView {
title: String,
@@ -47,6 +54,8 @@ pub(crate) struct CustomPromptView {
textarea_state: RefCell<TextAreaState>,
paste_burst: PasteBurst,
completion: Option<ViewCompletion>,
pending_suggestion: Option<PendingTextSuggestion>,
user_edited: bool,
}
impl CustomPromptView {
@@ -72,6 +81,8 @@ impl CustomPromptView {
textarea_state: RefCell::new(TextAreaState::default()),
paste_burst: PasteBurst::default(),
completion: None,
pending_suggestion: None,
user_edited: false,
}
}
@@ -86,9 +97,31 @@ impl CustomPromptView {
self.textarea.enter_vim_insert_mode();
}
/// Opt this prompt into one correlated background text suggestion.
pub(crate) fn with_text_suggestion(
mut self,
request_id: Uuid,
loading_label: String,
applied_label: String,
) -> Self {
self.context_label = Some(loading_label);
self.pending_suggestion = Some(PendingTextSuggestion {
request_id,
applied_label,
});
self
}
fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) {
let previous_text = self
.pending_suggestion
.as_ref()
.filter(|_| !self.user_edited)
.map(|_| self.textarea.text().to_string());
if self.textarea.is_vim_operator_pending() {
self.textarea.input(key_event);
self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text);
return;
}
@@ -113,6 +146,7 @@ impl CustomPromptView {
{
self.paste_burst.extend_window(now);
self.textarea.insert_str("\n");
self.user_edited = true;
return;
}
if modifiers == KeyModifiers::NONE {
@@ -152,6 +186,8 @@ impl CustomPromptView {
self.paste_burst.clear_after_explicit_paste();
}
}
self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text);
}
}
@@ -186,9 +222,35 @@ impl BottomPaneView for CustomPromptView {
return false;
}
self.textarea.insert_str(&pasted);
self.user_edited = true;
self.paste_burst.clear_after_explicit_paste();
true
}
fn apply_text_suggestion(&mut self, request_id: Uuid, suggestion: Option<&str>) -> bool {
if self.completion.is_some() {
return false;
}
let Some(pending) = self
.pending_suggestion
.take_if(|pending| pending.request_id == request_id)
else {
return false;
};
self.context_label = None;
if !self.user_edited
&& let Some(suggestion) = suggestion
{
self.textarea.set_text_clearing_elements(suggestion);
self.textarea.set_cursor(suggestion.len());
self.context_label = Some(pending.applied_label);
}
true
}
}
#[cfg(test)]
@@ -210,6 +210,83 @@ fn escape_without_vim_cancels_prompt() {
assert_eq!(view.completion(), Some(ViewCompletion::Cancelled));
}
#[test]
fn background_prefill_requires_a_matching_unedited_prompt() {
let request_id = Uuid::new_v4();
let (view, submitted_rx) = custom_prompt_view();
let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
assert!(!view.apply_text_suggestion(Uuid::new_v4(), Some("Stale")));
assert!(view.apply_text_suggestion(request_id, Some("Suggested text")));
assert_eq!(view.context_label.as_deref(), Some("Ready"));
view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), Instant::now());
assert_eq!(submitted_rx.try_recv(), Ok("Suggested text".to_string()));
assert!(!view.apply_text_suggestion(request_id, Some("Late")));
}
#[test]
fn background_prefill_failure_clears_loading_without_changing_text() {
let request_id = Uuid::new_v4();
let (view, _submitted_rx) = custom_prompt_view();
let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
view.textarea.set_text_clearing_elements("Existing title");
assert!(view.apply_text_suggestion(request_id, /*suggestion*/ None));
assert_eq!(
(view.textarea.text(), view.context_label.as_deref()),
("Existing title", None)
);
}
#[test]
fn background_prefill_preserves_typing_even_after_the_text_is_deleted() {
let request_id = Uuid::new_v4();
let (view, _submitted_rx) = custom_prompt_view();
let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
view.handle_key_event_at(KeyEvent::from(KeyCode::Char('x')), Instant::now());
view.handle_key_event_at(KeyEvent::from(KeyCode::Backspace), Instant::now());
assert!(view.apply_text_suggestion(request_id, Some("Generated")));
assert_eq!(
(view.textarea.text(), view.context_label.as_deref()),
("", None)
);
}
#[test]
fn background_prefill_preserves_pasted_text() {
let request_id = Uuid::new_v4();
let (view, _submitted_rx) = custom_prompt_view();
let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
assert!(view.handle_paste("Manual title".to_string()));
assert!(view.apply_text_suggestion(request_id, Some("Generated")));
assert_eq!(
(view.textarea.text(), view.context_label.as_deref()),
("Manual title", None)
);
}
#[test]
fn background_prefill_survives_cursor_movement() {
let request_id = Uuid::new_v4();
let (view, _submitted_rx) = custom_prompt_view();
let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
view.textarea.set_text_clearing_elements("Existing");
view.textarea.set_cursor("Existing".len());
view.handle_key_event_at(KeyEvent::from(KeyCode::Left), Instant::now());
assert!(view.apply_text_suggestion(request_id, Some("Generated")));
assert_eq!(
(view.textarea.text(), view.context_label.as_deref()),
("Generated", Some("Ready"))
);
}
fn custom_prompt_view() -> (CustomPromptView, Receiver<String>) {
let (submitted, submitted_rx) = std::sync::mpsc::channel();
let view = CustomPromptView::new(
+54
View File
@@ -1303,6 +1303,25 @@ impl BottomPane {
.and_then(|view| view.active_tab_id())
}
/// Forward a suggestion to its matching prompt, even beneath another view.
pub(crate) fn apply_text_suggestion(
&mut self,
request_id: uuid::Uuid,
suggestion: Option<&str>,
) -> bool {
let changed = self
.view_stack
.iter_mut()
.rev()
.any(|view| view.apply_text_suggestion(request_id, suggestion));
if changed {
self.request_redraw();
}
changed
}
pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool {
let is_match = self
.view_stack
@@ -2322,6 +2341,41 @@ mod tests {
);
}
#[test]
fn apply_text_suggestion_updates_matching_prompt_beneath_an_overlay() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = test_pane(tx);
let request_id = uuid::Uuid::new_v4();
let prompt = custom_prompt_view::CustomPromptView::new(
"Rename thread".to_string(),
"Type a name".to_string(),
/*initial_text*/ String::new(),
/*context_label*/ None,
Box::new(|_| {}),
)
.with_text_suggestion(request_id, "Loading".to_string(), "Ready".to_string());
pane.show_text_prompt(prompt);
pane.push_view(Box::new(CompletingView {
id: Some("overlay"),
complete: false,
}));
assert!(pane.apply_text_suggestion(request_id, Some("Fix login timeout")));
pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let rendered = render_snapshot(
&pane,
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8,
),
);
assert!(rendered.contains("Fix login timeout"));
assert!(rendered.contains("Ready"));
assert!(!rendered.contains("Loading"));
}
#[test]
fn dismiss_app_server_request_returns_false_when_no_view_matches() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();