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

+18
View File
@@ -2472,6 +2472,13 @@ impl App {
} }
} }
} }
AppEvent::SuggestThreadName {
thread_id,
request_id,
} => {
self.suggest_thread_name(app_server, thread_id, request_id)
.await;
}
AppEvent::ThreadTitleStarted { AppEvent::ThreadTitleStarted {
thread_id, thread_id,
destination, destination,
@@ -2513,6 +2520,17 @@ impl App {
} }
} }
} }
ThreadTitleDestination::RenameSuggestion { request_id } => {
let suggestion = result
.ok()
.and_then(|response| super::thread_title::parse_thread_title(&response));
self.chat_widget.apply_thread_name_suggestion(
thread_id,
request_id,
suggestion.as_deref(),
);
}
} }
} }
AppEvent::StopAgentsOverviewThread { thread_id } => { AppEvent::StopAgentsOverviewThread { thread_id } => {
+177
View File
@@ -1,13 +1,18 @@
//! Structured-output schema and normalization for generated TUI thread titles. //! Structured-output schema and normalization for generated TUI thread titles.
use super::App; use super::App;
use super::thread_events::ThreadBufferedEvent;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event::ThreadTitleDestination; use crate::app_event::ThreadTitleDestination;
use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerSession;
use crate::temporary_structured_request::TemporaryStructuredThreadOptions; use crate::temporary_structured_request::TemporaryStructuredThreadOptions;
use crate::temporary_structured_request::run_temporary_structured_turn; use crate::temporary_structured_request::run_temporary_structured_turn;
use crate::temporary_structured_request::start_temporary_thread; use crate::temporary_structured_request::start_temporary_thread;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::models::MessagePhase;
use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffort;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
@@ -17,6 +22,7 @@ use tokio::sync::mpsc;
pub(super) const THREAD_TITLE_MAX_CHARS: usize = 36; pub(super) const THREAD_TITLE_MAX_CHARS: usize = 36;
const THREAD_TITLE_MODEL: &str = "gpt-5.6-luna"; const THREAD_TITLE_MODEL: &str = "gpt-5.6-luna";
pub(super) const THREAD_TITLE_PROMPT_MAX_BYTES: usize = 960; pub(super) const THREAD_TITLE_PROMPT_MAX_BYTES: usize = 960;
const THREAD_TITLE_RECENT_MESSAGES: usize = 8;
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -90,11 +96,20 @@ impl App {
Ok(thread_id) => thread_id, Ok(thread_id) => thread_id,
Err(error) => { Err(error) => {
tracing::debug!(%error, "failed to start title-generation thread"); tracing::debug!(%error, "failed to start title-generation thread");
if let ThreadTitleDestination::RenameSuggestion { request_id } = destination {
self.chat_widget.apply_thread_name_suggestion(
thread_id, request_id, /*suggestion*/ None,
);
}
return; return;
} }
}; };
let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else { let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else {
if let ThreadTitleDestination::RenameSuggestion { request_id } = destination {
self.chat_widget
.apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None);
}
return; return;
}; };
@@ -124,6 +139,62 @@ impl App {
}); });
}); });
} }
/// Suggest a title from stored and live conversation items without persisting it.
pub(super) async fn suggest_thread_name(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
request_id: uuid::Uuid,
) {
if self.chat_widget.thread_id() != Some(thread_id) {
return;
}
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
self.chat_widget
.apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None);
return;
};
let conversation = {
let store = channel.store.lock().await;
let mut seen = std::collections::HashSet::new();
recent_conversation_messages(
store
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.chain(store.buffer.iter().filter_map(|event| {
let ThreadBufferedEvent::Notification(notification) = event else {
return None;
};
let ServerNotification::ItemCompleted(notification) = notification.as_ref()
else {
return None;
};
Some(&notification.item)
}))
.filter(|item| seen.insert(item.id().to_string())),
)
};
let Some(conversation) = conversation else {
self.chat_widget
.apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None);
return;
};
self.generate_thread_title(
app_server,
thread_id,
ThreadTitleDestination::RenameSuggestion { request_id },
recent_conversation_thread_title_prompt(&conversation),
);
}
} }
/// Constrain generated metadata to one nonempty title within the display limit. /// Constrain generated metadata to one nonempty title within the display limit.
@@ -169,6 +240,112 @@ pub(super) fn thread_title_prompt(user_message: &str) -> String {
format!("{prefix}{user_message}") format!("{prefix}{user_message}")
} }
/// Format recent substantive messages chronologically without trusting their markup.
pub(super) fn recent_conversation_messages<'a, I>(items: I) -> Option<String>
where
I: IntoIterator<Item = &'a ThreadItem>,
I::IntoIter: DoubleEndedIterator,
{
let mut messages = items
.into_iter()
.rev()
.filter_map(|item| match item {
ThreadItem::UserMessage { content, .. } => {
let text = content
.iter()
.filter_map(|input| match input {
UserInput::Text { text, .. } => {
Some(crate::ide_context::extract_prompt_request_with_offset(text).0)
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then_some(("user", text))
}
ThreadItem::AgentMessage { text, phase, .. }
if !matches!(phase, Some(MessagePhase::Commentary)) && !text.trim().is_empty() =>
{
Some(("assistant", text.clone()))
}
_ => None,
})
.take(THREAD_TITLE_RECENT_MESSAGES)
.map(|(role, text)| {
let escaped = text
.trim()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
(role, escaped)
})
.collect::<Vec<_>>();
if messages.is_empty() {
return None;
}
messages.reverse();
let conversation_bytes = THREAD_TITLE_PROMPT_MAX_BYTES
.saturating_sub(recent_conversation_thread_title_prompt("").len());
let markup_bytes = "<conversation>\n".len()
+ "\n</conversation>".len()
+ messages.len().saturating_sub(/*rhs*/ 1)
+ messages
.iter()
.map(|(role, _)| "<message role=\"\"></message>".len() + role.len())
.sum::<usize>();
let message_bytes = conversation_bytes.saturating_sub(markup_bytes) / messages.len();
let content_bytes = messages.iter().map(|(_, text)| text.len()).sum::<usize>();
let should_truncate = content_bytes > conversation_bytes.saturating_sub(markup_bytes);
let messages = messages
.into_iter()
.map(|(role, mut text)| {
if should_truncate && text.len() > message_bytes {
let mut end = message_bytes;
while !text.is_char_boundary(end) {
end -= 1;
}
if let Some(entity_start) = text[..end].rfind('&')
&& !text[entity_start..end].contains(';')
{
end = entity_start;
}
text.truncate(end);
}
format!("<message role=\"{role}\">{text}</message>")
})
.collect::<Vec<_>>();
Some(format!(
"<conversation>\n{}\n</conversation>",
messages.join("\n")
))
}
/// Bound the entire suggestion prompt while preserving complete Unicode characters.
pub(super) fn recent_conversation_thread_title_prompt(conversation: &str) -> String {
let instructions = thread_title_instructions();
let prefix = format!(
"{instructions}\n\
Prioritize the current task and latest substantive user request.\n\n\
Recent conversation messages:\n"
);
let conversation = conversation.trim();
let remaining_bytes = THREAD_TITLE_PROMPT_MAX_BYTES.saturating_sub(prefix.len());
let mut start = conversation.len().saturating_sub(remaining_bytes);
while !conversation.is_char_boundary(start) {
start += 1;
}
let conversation = &conversation[start..];
format!("{prefix}{conversation}")
}
/// Normalize a generated title and truncate it without splitting Unicode characters. /// Normalize a generated title and truncate it without splitting Unicode characters.
pub(super) fn parse_thread_title(response: &str) -> Option<String> { pub(super) fn parse_thread_title(response: &str) -> Option<String> {
if !response.trim_start().starts_with('{') { if !response.trim_start().starts_with('{') {
+320
View File
@@ -1,10 +1,31 @@
use super::THREAD_TITLE_MAX_CHARS; use super::THREAD_TITLE_MAX_CHARS;
use super::THREAD_TITLE_PROMPT_MAX_BYTES; use super::THREAD_TITLE_PROMPT_MAX_BYTES;
use super::THREAD_TITLE_RECENT_MESSAGES;
use super::parse_thread_title; use super::parse_thread_title;
use super::recent_conversation_messages;
use super::recent_conversation_thread_title_prompt;
use super::thread_title_prompt; use super::thread_title_prompt;
use crate::app::session_lifecycle::ThreadAttachPresentation;
use crate::app::test_support::make_test_app; use crate::app::test_support::make_test_app;
use crate::app_command::AppCommand; use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::test_support::PathBufExt;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::models::MessagePhase;
use core_test_support::responses;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tokio::sync::mpsc::unbounded_channel;
const EXPECTED_THREAD_TITLE_INSTRUCTIONS: &str = concat!( const EXPECTED_THREAD_TITLE_INSTRUCTIONS: &str = concat!(
"Generate a concise, single-line task title of at most 36 characters ", "Generate a concise, single-line task title of at most 36 characters ",
@@ -91,6 +112,284 @@ async fn manual_rename_invalidates_pending_automatic_title_before_notification()
Ok(()) Ok(())
} }
#[tokio::test]
async fn slash_rename_generates_editable_title_through_embedded_app_server()
-> color_eyre::Result<()> {
let server = wiremock::MockServer::start().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("title-response"),
responses::ev_assistant_message("title-message", r#"{"title":"Fix login timeout"}"#),
responses::ev_completed("title-response"),
]),
)
.await;
let codex_home = tempdir()?;
let provider_id = "thread-title-test";
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"model = \"gpt-5.2\"\n\
model_provider = \"{provider_id}\"\n\n\
[model_providers.{provider_id}]\n\
name = \"Thread title test\"\n\
base_url = \"{}/v1\"\n\
wire_api = \"responses\"\n\
request_max_retries = 0\n\
stream_max_retries = 0\n",
server.uri()
),
)?;
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = unbounded_channel();
app.app_event_tx = AppEventSender::new(event_tx);
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite =
codex_state::SqliteConfig::new_for_testing(codex_home.path().to_path_buf().abs());
app.config.model = Some("gpt-5.2".to_string());
app.config.model_provider_id = provider_id.to_string();
app.config.model_provider = ModelProviderInfo {
name: "Thread title test".to_string(),
base_url: Some(format!("{}/v1", server.uri())),
request_max_retries: Some(0),
stream_max_retries: Some(0),
..ModelProviderInfo::default()
};
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.replace_chat_widget_with_app_server_thread(
&mut tui,
started,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await?;
app.ensure_thread_channel(thread_id)
.store
.lock()
.await
.turns
.push(Turn {
id: "existing-turn".to_string(),
items: vec![title_user_message("user-message", "Fix the login timeout")],
items_view: Default::default(),
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
});
while event_rx.try_recv().is_ok() {}
app.chat_widget.apply_external_edit("/rename".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Generating"));
enum TitleDriveEvent {
Ui(Box<AppEvent>),
Server(AppServerEvent),
}
loop {
let event = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async {
tokio::select! {
event = event_rx.recv() => event.map(|event| TitleDriveEvent::Ui(Box::new(event))),
event = app_server.next_event() => event.map(TitleDriveEvent::Server),
}
})
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("title event stream ended"))?;
match event {
TitleDriveEvent::Ui(event) => {
let generated = matches!(event.as_ref(), AppEvent::GeneratedThreadTitle { .. });
app.handle_event(&mut tui, &mut app_server, *event).await?;
if generated {
break;
}
}
TitleDriveEvent::Server(event) => {
app.handle_app_server_event(&app_server, event).await;
}
}
}
let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80);
assert!(popup.contains("Fix login timeout"));
assert!(!popup.contains("Generating a title suggestion"));
assert!(app.temporary_structured_requests.is_empty());
let request = response.single_request();
assert!(
request
.body_json()
.to_string()
.contains("Fix the login timeout")
);
app_server.shutdown().await?;
Ok(())
}
#[test]
fn recent_conversation_messages_escape_markup_and_ignore_commentary() {
let items = vec![
title_user_message("user-1", " Investigate <flaky> & slow tests "),
title_agent_message(
"commentary",
"Checking files",
Some(MessagePhase::Commentary),
),
title_user_message("user-2", "Fix > flaky tests"),
title_agent_message(
"answer",
" Tests now pass ",
Some(MessagePhase::FinalAnswer),
),
];
assert_eq!(
recent_conversation_messages(&items),
Some(
"<conversation>\n<message role=\"user\">Investigate &lt;flaky&gt; &amp; slow tests</message>\n<message role=\"user\">Fix &gt; flaky tests</message>\n<message role=\"assistant\">Tests now pass</message>\n</conversation>"
.to_string()
)
);
}
#[test]
fn recent_conversation_messages_strip_ide_context_before_escaping() {
let ide_context = "x".repeat(THREAD_TITLE_PROMPT_MAX_BYTES + 1);
let user_message = format!(
"# Context from my IDE setup:\n{ide_context}\n## My request for Codex:\n\nEarlier request\n## My request for Codex:\n Fix <login> & retries "
);
let items = vec![title_user_message("user-1", &user_message)];
assert_eq!(
recent_conversation_messages(&items),
Some(
"<conversation>\n<message role=\"user\">Fix &lt;login&gt; &amp; retries</message>\n</conversation>"
.to_string()
)
);
}
#[test]
fn recent_conversation_messages_keep_only_the_latest_substantive_items() {
let items = (0..THREAD_TITLE_RECENT_MESSAGES + 2)
.map(|index| title_user_message(&index.to_string(), &format!("message-{index}")))
.collect::<Vec<_>>();
let expected_messages = (2..THREAD_TITLE_RECENT_MESSAGES + 2)
.map(|index| format!("<message role=\"user\">message-{index}</message>"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(
recent_conversation_messages(&items),
Some(format!(
"<conversation>\n{expected_messages}\n</conversation>"
))
);
}
#[test]
fn recent_conversation_messages_require_substantive_content() {
assert_eq!(recent_conversation_messages(&[]), None);
let items = vec![
title_user_message("blank", " \n\t "),
title_agent_message("commentary", "Thinking", Some(MessagePhase::Commentary)),
];
assert_eq!(recent_conversation_messages(&items), None);
}
#[test]
fn recent_conversation_prompt_trims_messages_and_prioritizes_latest_request() {
let prompt = recent_conversation_thread_title_prompt(
" User: Fix login\nAssistant: Investigating the auth flow \n",
);
assert_eq!(
prompt,
format!(
"{EXPECTED_THREAD_TITLE_INSTRUCTIONS}\nPrioritize the current task and latest substantive user request.\n\nRecent conversation messages:\nUser: Fix login\nAssistant: Investigating the auth flow"
)
);
}
#[test]
fn recent_conversation_prompt_keeps_latest_unicode_characters() {
let conversation = format!("discarded-{}x", "🚀".repeat(THREAD_TITLE_PROMPT_MAX_BYTES));
let prompt = recent_conversation_thread_title_prompt(&conversation);
let expected_instructions = format!(
"{EXPECTED_THREAD_TITLE_INSTRUCTIONS}\nPrioritize the current task and latest substantive user request.\n\n"
);
let prefix = format!("{expected_instructions}Recent conversation messages:\n");
let remaining_bytes = THREAD_TITLE_PROMPT_MAX_BYTES - prefix.len();
let expected = format!("{}x", "🚀".repeat((remaining_bytes - 1) / '🚀'.len_utf8()));
assert_eq!(
prompt.rsplit_once("Recent conversation messages:\n"),
Some((expected_instructions.as_str(), expected.as_str()))
);
assert!(prompt.len() <= THREAD_TITLE_PROMPT_MAX_BYTES);
}
#[test]
fn recent_conversation_prompt_preserves_latest_user_request_and_complete_markup() {
let items = vec![
title_user_message("user", "Fix <authentication> & login regressions"),
title_agent_message(
"assistant",
&format!("{}&<>", "🚀".repeat(THREAD_TITLE_PROMPT_MAX_BYTES)),
Some(MessagePhase::FinalAnswer),
),
];
let conversation = recent_conversation_messages(&items).expect("substantive conversation");
let prompt = recent_conversation_thread_title_prompt(&conversation);
assert!(prompt.len() <= THREAD_TITLE_PROMPT_MAX_BYTES);
assert!(prompt.contains("<conversation>\n"));
assert!(prompt.contains("</conversation>"));
assert!(prompt.contains(
"<message role=\"user\">Fix &lt;authentication&gt; &amp; login regressions</message>"
));
assert!(prompt.contains("<message role=\"assistant\">"));
assert_eq!(prompt.matches("<message role=").count(), 2);
assert_eq!(prompt.matches("</message>").count(), 2);
}
#[test]
fn recent_conversation_prompt_never_splits_an_escaped_markup_entity() {
let items = vec![title_user_message(
"user",
&"<&>".repeat(THREAD_TITLE_PROMPT_MAX_BYTES),
)];
let conversation = recent_conversation_messages(&items).expect("substantive conversation");
let prompt = recent_conversation_thread_title_prompt(&conversation);
assert!(prompt.len() <= THREAD_TITLE_PROMPT_MAX_BYTES);
let text = conversation
.strip_prefix("<conversation>\n<message role=\"user\">")
.and_then(|text| text.strip_suffix("</message>\n</conversation>"))
.expect("complete message markup");
for fragment in text.split('&').skip(/*n*/ 1) {
assert!(
fragment.starts_with("lt;")
|| fragment.starts_with("gt;")
|| fragment.starts_with("amp;")
);
}
}
#[test] #[test]
fn normalizes_generated_title_whitespace() { fn normalizes_generated_title_whitespace() {
assert_eq!( assert_eq!(
@@ -161,3 +460,24 @@ fn truncates_generated_titles_without_splitting_unicode() {
assert_eq!(parse_thread_title(&response), Some(expected)); assert_eq!(parse_thread_title(&response), Some(expected));
} }
fn title_user_message(id: &str, text: &str) -> ThreadItem {
ThreadItem::UserMessage {
id: id.to_string(),
client_id: None,
content: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
}
}
fn title_agent_message(id: &str, text: &str, phase: Option<MessagePhase>) -> ThreadItem {
ThreadItem::AgentMessage {
id: id.to_string(),
text: text.to_string(),
phase,
memory_citation: None,
delivery: None,
}
}
+8 -1
View File
@@ -195,11 +195,13 @@ pub(crate) enum TranscriptExportDestination {
File(PathBuf), File(PathBuf),
} }
/// Deliver a generated title to its originating automatic rename. /// Deliver a generated title to its originating automatic rename or editable prompt.
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum ThreadTitleDestination { pub(crate) enum ThreadTitleDestination {
/// Replace the provisional name only if the user has not renamed the thread. /// Replace the provisional name only if the user has not renamed the thread.
Automatic { expected_title: String }, Automatic { expected_title: String },
/// Prefill only the still-active rename prompt with the matching request ID.
RenameSuggestion { request_id: Uuid },
} }
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
@@ -226,6 +228,11 @@ pub(crate) enum AppEvent {
thread_id: ThreadId, thread_id: ThreadId,
name: String, name: String,
}, },
/// Generate an editable title suggestion for the active rename prompt.
SuggestThreadName {
thread_id: ThreadId,
request_id: Uuid,
},
/// Register a hidden title-generation thread started in the background. /// Register a hidden title-generation thread started in the background.
ThreadTitleStarted { ThreadTitleStarted {
thread_id: ThreadId, thread_id: ThreadId,
@@ -56,6 +56,15 @@ pub(crate) trait BottomPaneView: Renderable {
None 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. /// Active tab id for tabbed list-based views.
#[allow(dead_code)] #[allow(dead_code)]
fn active_tab_id(&self) -> Option<&str> { fn active_tab_id(&self) -> Option<&str> {
@@ -12,6 +12,7 @@ use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use std::cell::RefCell; use std::cell::RefCell;
use std::time::Instant; use std::time::Instant;
use uuid::Uuid;
use crate::key_hint; use crate::key_hint;
use crate::key_hint::has_ctrl_or_alt; 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. /// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>; 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. /// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView { pub(crate) struct CustomPromptView {
title: String, title: String,
@@ -47,6 +54,8 @@ pub(crate) struct CustomPromptView {
textarea_state: RefCell<TextAreaState>, textarea_state: RefCell<TextAreaState>,
paste_burst: PasteBurst, paste_burst: PasteBurst,
completion: Option<ViewCompletion>, completion: Option<ViewCompletion>,
pending_suggestion: Option<PendingTextSuggestion>,
user_edited: bool,
} }
impl CustomPromptView { impl CustomPromptView {
@@ -72,6 +81,8 @@ impl CustomPromptView {
textarea_state: RefCell::new(TextAreaState::default()), textarea_state: RefCell::new(TextAreaState::default()),
paste_burst: PasteBurst::default(), paste_burst: PasteBurst::default(),
completion: None, completion: None,
pending_suggestion: None,
user_edited: false,
} }
} }
@@ -86,9 +97,31 @@ impl CustomPromptView {
self.textarea.enter_vim_insert_mode(); 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) { 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() { if self.textarea.is_vim_operator_pending() {
self.textarea.input(key_event); self.textarea.input(key_event);
self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text);
return; return;
} }
@@ -113,6 +146,7 @@ impl CustomPromptView {
{ {
self.paste_burst.extend_window(now); self.paste_burst.extend_window(now);
self.textarea.insert_str("\n"); self.textarea.insert_str("\n");
self.user_edited = true;
return; return;
} }
if modifiers == KeyModifiers::NONE { if modifiers == KeyModifiers::NONE {
@@ -152,6 +186,8 @@ impl CustomPromptView {
self.paste_burst.clear_after_explicit_paste(); 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; return false;
} }
self.textarea.insert_str(&pasted); self.textarea.insert_str(&pasted);
self.user_edited = true;
self.paste_burst.clear_after_explicit_paste(); self.paste_burst.clear_after_explicit_paste();
true 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)] #[cfg(test)]
@@ -210,6 +210,83 @@ fn escape_without_vim_cancels_prompt() {
assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); 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>) { fn custom_prompt_view() -> (CustomPromptView, Receiver<String>) {
let (submitted, submitted_rx) = std::sync::mpsc::channel(); let (submitted, submitted_rx) = std::sync::mpsc::channel();
let view = CustomPromptView::new( let view = CustomPromptView::new(
+54
View File
@@ -1303,6 +1303,25 @@ impl BottomPane {
.and_then(|view| view.active_tab_id()) .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 { pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool {
let is_match = self let is_match = self
.view_stack .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] #[test]
fn dismiss_app_server_request_returns_false_when_no_view_matches() { fn dismiss_app_server_request_returns_false_when_no_view_matches() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
+29 -1
View File
@@ -417,7 +417,10 @@ impl ChatWidget {
} else { } else {
"Name thread" "Name thread"
}; };
let view = CustomPromptView::new( let suggestion_request = self
.thread_id
.map(|thread_id| (thread_id, uuid::Uuid::new_v4()));
let mut view = CustomPromptView::new(
title.to_string(), title.to_string(),
"Type a name and press Enter".to_string(), "Type a name and press Enter".to_string(),
/*initial_text*/ existing_name.unwrap_or_default().to_string(), /*initial_text*/ existing_name.unwrap_or_default().to_string(),
@@ -432,7 +435,32 @@ impl ChatWidget {
tx.set_thread_name(name); tx.set_thread_name(name);
}), }),
); );
if let Some((_, request_id)) = suggestion_request {
view = view.with_text_suggestion(
request_id,
"Generating a title suggestion…".to_string(),
"Suggested from this conversation".to_string(),
);
}
self.bottom_pane.show_text_prompt(view); self.bottom_pane.show_text_prompt(view);
if let Some((thread_id, request_id)) = suggestion_request {
self.app_event_tx.send(AppEvent::SuggestThreadName {
thread_id,
request_id,
});
}
}
pub(crate) fn apply_thread_name_suggestion(
&mut self,
thread_id: ThreadId,
request_id: uuid::Uuid,
suggestion: Option<&str>,
) {
if self.thread_id == Some(thread_id) {
self.bottom_pane
.apply_text_suggestion(request_id, suggestion);
}
} }
pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool { pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool {
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
▌ Rename thread
▌ Generating a title suggestion…
▌
▌ Current project title
Press enter to confirm or esc to go back
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
▌ Rename thread
▌ Suggested from this conversation
▌
▌ Fix login timeout
Press enter to confirm or esc to go back
@@ -1621,6 +1621,37 @@ async fn live_app_server_manual_thread_name_is_visible_before_notification() {
assert_eq!(drain_insert_history(&mut rx).len(), 1); assert_eq!(drain_insert_history(&mut rx).len(), 1);
} }
#[tokio::test]
async fn thread_name_suggestion_requires_matching_thread_and_request() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
let request_id = uuid::Uuid::new_v4();
chat.thread_id = Some(thread_id);
let view = CustomPromptView::new(
"Rename thread".to_string(),
"Type a name and press Enter".to_string(),
/*initial_text*/ String::new(),
/*context_label*/ None,
Box::new(|_| {}),
)
.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
chat.bottom_pane.show_text_prompt(view);
chat.apply_thread_name_suggestion(ThreadId::new(), request_id, Some("Wrong thread"));
chat.apply_thread_name_suggestion(thread_id, uuid::Uuid::new_v4(), Some("Wrong request"));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(popup.contains("Loading"));
assert!(!popup.contains("Wrong"));
chat.apply_thread_name_suggestion(thread_id, request_id, Some("Suggested title"));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(popup.contains("Ready"));
assert!(popup.contains("Suggested title"));
}
#[tokio::test] #[tokio::test]
async fn live_app_server_thread_closed_requests_immediate_exit() { async fn live_app_server_thread_closed_requests_immediate_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1214,6 +1214,78 @@ async fn slash_rename_prefills_existing_thread_name() {
); );
} }
#[tokio::test]
async fn slash_rename_requests_and_prefills_an_editable_title_suggestion() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.thread_name = Some("Current project title".to_string());
chat.dispatch_command(SlashCommand::Rename);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("slash_rename_suggestion_loading", popup);
let request_id = match rx.try_recv() {
Ok(AppEvent::SuggestThreadName {
thread_id: requested_thread_id,
request_id,
}) => {
assert_eq!(requested_thread_id, thread_id);
request_id
}
other => panic!("expected title suggestion request, got {other:?}"),
};
chat.apply_thread_name_suggestion(thread_id, request_id, Some("Fix login timeout"));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("slash_rename_suggestion_prefilled", popup);
assert_eq!(chat.thread_name.as_deref(), Some("Current project title"));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Fix login timeout"
);
}
#[tokio::test]
async fn slash_rename_preserves_manual_edits_when_suggestion_arrives() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.dispatch_command(SlashCommand::Rename);
let request_id = match rx.try_recv() {
Ok(AppEvent::SuggestThreadName {
thread_id: requested_thread_id,
request_id,
}) => {
assert_eq!(requested_thread_id, thread_id);
request_id
}
other => panic!("expected title suggestion request, got {other:?}"),
};
chat.handle_paste("My own title".to_string());
chat.apply_thread_name_suggestion(thread_id, request_id, Some("Generated title"));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(popup.contains("My own title"));
assert!(!popup.contains("Generated title"));
assert!(!popup.contains("Generating a title suggestion"));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "My own title"
);
}
#[tokio::test] #[tokio::test]
async fn slash_rename_without_existing_thread_name_starts_empty() { async fn slash_rename_without_existing_thread_name_starts_empty() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;