mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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(
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user