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
@@ -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 } => {
|
||||||
|
|||||||
@@ -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(¬ification.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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">");
|
||||||
|
|
||||||
|
(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('{') {
|
||||||
|
|||||||
@@ -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 <flaky> & slow tests</message>\n<message role=\"user\">Fix > 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 <login> & 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 <authentication> & 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+10
@@ -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
|
||||||
+10
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user