Remove follow-up prompt suggestions from the TUI (#48621)

## What changed

Remove automatic next-message generation after successful turns, suggestion rendering in the composer, and the associated Tab acceptance and Escape dismissal handling. Remove the `tui.prompt_suggestions` configuration option and its schema entry, along with suggestion-specific tests and snapshots.

GitOrigin-RevId: 63bf6f2ca85da6ece502c86e2a805d000bf34210
This commit is contained in:
Eric Traut
2026-09-27 05:29:53 +00:00
committed by copyberry
parent 814de47b69
commit 334b6e7321
48 changed files with 49 additions and 1249 deletions
-4
View File
@@ -835,10 +835,6 @@ pub struct Tui {
#[serde(default = "default_true")]
pub auto_recap: bool,
/// Suggest a next message after successful turns. Defaults to `false`.
#[serde(default)]
pub prompt_suggestions: bool,
/// When true, disables burst-paste detection for typed input entirely.
/// All characters are inserted as they are received, and no buffering
/// or placeholder replacement will occur for fast keypress bursts.
-5
View File
@@ -4627,11 +4627,6 @@
"default": "composer",
"description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport."
},
"prompt_suggestions": {
"default": false,
"description": "Suggest a next message after successful turns. Defaults to `false`.",
"type": "boolean"
},
"question_esc_back": {
"default": true,
"description": "Escape returns from async questions to the composer, preserving the answer draft.",
-2
View File
@@ -1264,7 +1264,6 @@ fn config_toml_deserializes_model_availability_nux() {
show_tooltips: true,
show_server_version_notice: true,
auto_recap: true,
prompt_suggestions: false,
disable_paste_burst: None,
vim_mode_default: false,
question_esc_back: true,
@@ -4406,7 +4405,6 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
show_tooltips: true,
show_server_version_notice: true,
auto_recap: true,
prompt_suggestions: false,
disable_paste_burst: None,
vim_mode_default: false,
question_esc_back: true,
-4
View File
@@ -773,9 +773,6 @@ pub struct Config {
/// Generate automatic TUI recaps. Manual `/recap` remains available when disabled.
pub tui_auto_recap: bool,
/// Generate suggested next messages in the TUI composer.
pub tui_prompt_suggestions: bool,
/// Persisted startup availability NUX state for model tooltips.
pub model_availability_nux: ModelAvailabilityNuxConfig,
@@ -4475,7 +4472,6 @@ impl Config {
.map(|t| t.show_server_version_notice)
.unwrap_or(true),
tui_auto_recap: cfg.tui.as_ref().map(|t| t.auto_recap).unwrap_or(/*default*/ true),
tui_prompt_suggestions: cfg.tui.as_ref().is_some_and(|t| t.prompt_suggestions),
model_availability_nux: cfg
.tui
.as_ref()
@@ -284,7 +284,6 @@ async fn new_config(
show_tooltips: true,
tui_show_server_version_notice: true,
tui_auto_recap: true,
tui_prompt_suggestions: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
tui_fullscreen_transcript: false,
tui_copy_on_select: Default::default(),
@@ -1,5 +0,0 @@
This is an app-generated request for a suggested next user message. The user did not write this message.
Your suggestion will appear in the user's empty message composer. They can accept it, edit it, or ignore it; it will not be sent automatically.
Predict this user's next message from the whole conversation. Before choosing a new content question, consider whether they want an operation on material already produced. The object may be the assistant's own answer, a draft, a list, a comparison, or an external artifact. They may want to reuse it in a different form, make it easier to read or copy, obtain more items of the same kind, retry an earlier response after discussing its flaw, or make one precise change. Choose such a move only when the user's purpose, prior reactions, or intended use supports it. Preserve the actual object and requested deliverable; an adjacent explanation or extra content is a different action. If no transformation is supported, choose the strongest grounded question, reply, or continuation. Keep the necessary linked requirements, without inventing observations or a new agenda. Set suggestion to exactly one natural message in the user's voice, one line, at most 240 characters. No alternatives or explanation. Prefer a grounded prediction; use null only if every concrete next move is arbitrary.
-2
View File
@@ -240,7 +240,6 @@ mod permission_shortcuts;
mod pets;
mod platform_actions;
mod plugin_mentions;
mod prompt_suggestions;
mod rate_limit_refresh;
mod realtime_delivery;
mod realtime_settings;
@@ -648,7 +647,6 @@ pub(crate) struct App {
background_voice: Option<Box<ChatWidget>>,
background_voice_error: Option<(ThreadId, String)>,
temporary_structured_requests: HashMap<ThreadId, mpsc::UnboundedSender<ServerNotification>>,
hidden_prompt_threads: VecDeque<ThreadId>,
/// Track title generation across thread switches and deduplicate automatic requests.
pending_thread_titles: HashMap<(ThreadId, ThreadTitleDestination), CancellationToken>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
-37
View File
@@ -119,31 +119,6 @@ impl App {
app_server_client: &AppServerSession,
notification: ServerNotification,
) {
if let ServerNotification::ThreadStarted(started) = &notification
&& started.thread.ephemeral
&& matches!(started.thread.thread_source.as_ref(), Some(ThreadSource::Feature(source)) if source == "prompt_suggestion")
&& let Ok(id) = ThreadId::from_string(&started.thread.id)
{
self.hidden_prompt_threads.push_back(id);
if self.hidden_prompt_threads.len() > 32 {
self.hidden_prompt_threads.pop_front();
}
return;
}
if let ServerNotificationThreadTarget::Thread(id) =
server_notification_thread_target(&notification)
&& self.hidden_prompt_threads.contains(&id)
{
if let Some(sender) = self.temporary_structured_requests.get(&id)
&& matches!(
&notification,
ServerNotification::ItemCompleted(_) | ServerNotification::TurnCompleted(_)
)
{
let _ = sender.send(notification);
}
return;
}
// A picker can leave an old runtime's close notification queued while the same thread
// is resumed. Thread IDs survive reloads, so confirm that the displayed thread is still
// unloaded before routing a close that would exit the TUI or switch away from it.
@@ -542,18 +517,6 @@ impl App {
app_server_client: &AppServerSession,
request: ServerRequest,
) {
if server_request_thread_id(&request)
.is_some_and(|id| self.hidden_prompt_threads.contains(&id))
{
let _ = self
.reject_app_server_request(
app_server_client,
request.id().clone(),
"Prompt suggestions cannot request user interaction".to_string(),
)
.await;
return;
}
if let ServerRequest::DynamicToolCall { request_id, params } = &request {
if self.dynamic_tool_tasks.contains_key(request_id)
|| (params.namespace.as_deref() != Some(crate::dynamic_tools::NAMESPACE)
@@ -418,11 +418,6 @@ impl App {
.tui
.terminal_resize_reflow_max_rows =
self.local_settings.tui.terminal_resize_reflow_max_rows;
self.chat_widget.local_settings.tui.prompt_suggestions =
self.local_settings.tui.prompt_suggestions;
if !self.local_settings.tui.prompt_suggestions {
self.chat_widget.clear_prompt_suggestion();
}
self.config = config;
self.chat_widget.sync_plugin_mentions_config(&self.config);
Ok(())
-15
View File
@@ -50,8 +50,6 @@ impl App {
| AppEvent::ResetTranscriptForThreadSwitch
| AppEvent::ResetTranscriptForThreadSwitchPreservingScreen
| AppEvent::FinishPromptRevert { .. }
| AppEvent::PromptSuggestionStarted { .. }
| AppEvent::PromptSuggestionFinished { .. }
| AppEvent::ManagedWorktreeCreated(_)
| AppEvent::AgentsOverviewWorktreeCreated(_)
| AppEvent::AppendMessageHistoryEntry { .. }
@@ -2661,19 +2659,6 @@ impl App {
self.suggest_thread_name(app_server, thread_id, request_id)
.await;
}
AppEvent::GeneratePromptSuggestion(request) => {
self.generate_prompt_suggestion(app_server, request);
}
AppEvent::PromptSuggestionStarted { request, result } => {
self.on_prompt_suggestion_started(app_server, request, result);
}
AppEvent::PromptSuggestionFinished { request, temporary_thread_id, text } => {
self.temporary_structured_requests.remove(&temporary_thread_id);
if text.is_none() {
request.cancellation.cancel();
}
self.chat_widget.apply_prompt_suggestion(&request, text);
}
AppEvent::ThreadTitleStarted {
cancellation,
thread_id,
-1
View File
@@ -728,7 +728,6 @@ impl App {
pub(crate) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
!self.chat_widget.is_external_writer_view()
&& !self.chat_widget.has_prompt_suggestion()
&& !self.chat_widget.side_conversation_active()
&& !self.chat_widget.shortcut_overlay_visible()
&& self.chat_widget.is_normal_backtrack_mode()
-195
View File
@@ -1,195 +0,0 @@
//! Hidden fork requests. The composer owns request identity and cancellation, including
//! Escape during startup. A deadline cancels the result without dropping an in-flight fork.
use super::*;
use crate::prompt_suggestions::SuggestionRequest;
use crate::temporary_structured_request::collect_structured_response;
use crate::temporary_structured_request::unsubscribe_temporary_thread;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_context_fragments::AdditionalContextUserFragment;
use codex_context_fragments::ContextualUserFragment;
use codex_protocol::config_types::CollaborationMode;
use color_eyre::eyre::eyre;
const SUGGESTION_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
impl App {
pub(super) fn generate_prompt_suggestion(
&mut self,
app_server: &AppServerSession,
request: SuggestionRequest,
) {
if !self.local_settings.tui.prompt_suggestions
|| request.cancellation.is_cancelled()
|| self.chat_widget.thread_id() != Some(request.thread_id)
{
request.cancellation.cancel();
return;
}
let cancellation = request.cancellation.clone();
let finished = request.generation_finished.clone();
tokio::spawn(async move {
tokio::select! {
_ = cancellation.cancelled() => {},
_ = finished.cancelled() => {},
_ = tokio::time::sleep(SUGGESTION_TIMEOUT) => cancellation.cancel(),
}
});
let handle = app_server.request_handle();
let events = self.app_event_tx.clone();
tokio::spawn(async move {
// Await startup even after cancellation so the returned fork can be detached.
// Fork reloads config files; only exposed live settings can be preserved here.
let mut result = async {
let parent: ThreadResumeResponse = handle
.request_typed(ClientRequest::ThreadResume {
request_id: RequestId::String(format!("suggestion-resume-{}", request.id)),
params: ThreadResumeParams {
thread_id: request.thread_id.to_string(),
exclude_turns: true,
..Default::default()
},
})
.await?;
if request.cancellation.is_cancelled() {
return Err(eyre!("suggestion cancelled"));
}
let mut config = std::collections::HashMap::from([(
"model_reasoning_summary".to_string(),
serde_json::to_value(request.summary)?,
)]);
if let Some(effort) = parent.reasoning_effort {
config.insert(
"model_reasoning_effort".to_string(),
serde_json::to_value(effort)?,
);
}
let fork: ThreadForkResponse = handle
.request_typed(ClientRequest::ThreadFork {
request_id: RequestId::String(format!("suggestion-fork-{}", request.id)),
params: ThreadForkParams {
thread_id: request.thread_id.to_string(),
last_turn_id: Some(request.turn_id.clone()),
model: Some(parent.model),
model_provider: Some(parent.model_provider),
service_tier: Some(parent.service_tier),
runtime_workspace_roots: Some(parent.runtime_workspace_roots),
config: Some(config),
ephemeral: true,
exclude_turns: true,
thread_source: Some(ThreadSource::Feature(
"prompt_suggestion".to_string(),
)),
..Default::default()
},
})
.await?;
color_eyre::eyre::Ok((fork.thread.id, parent.collaboration_mode))
}
.await
.map_err(|error| error.to_string());
if request.cancellation.is_cancelled() {
if let Ok((thread_id, _)) = result {
unsubscribe_temporary_thread(&handle, thread_id).await;
}
result = Err("suggestion cancelled".to_string());
}
events.send(AppEvent::PromptSuggestionStarted { request, result });
});
}
pub(super) fn on_prompt_suggestion_started(
&mut self,
app_server: &AppServerSession,
request: SuggestionRequest,
result: Result<(String, Option<CollaborationMode>), String>,
) {
let Ok((thread_id, collaboration_mode)) = result else {
request.generation_finished.cancel();
request.cancellation.cancel();
return;
};
let handle = app_server.request_handle();
let Ok(temporary_thread_id) = ThreadId::from_string(&thread_id) else {
request.generation_finished.cancel();
request.cancellation.cancel();
tokio::spawn(async move {
unsubscribe_temporary_thread(&handle, thread_id).await;
});
return;
};
let (sender, receiver) = mpsc::unbounded_channel();
self.temporary_structured_requests
.insert(temporary_thread_id, sender);
let events = self.app_event_tx.clone();
tokio::spawn(async move {
// Retain the inherited tools and settings. Await the turn ID before interrupting.
let mut turn_id = None;
let result = async {
if request.cancellation.is_cancelled() {
return Err(eyre!("suggestion cancelled"));
}
let response: TurnStartResponse = handle.request_typed(ClientRequest::TurnStart {
request_id: RequestId::String(format!("suggestion-turn-{}", Uuid::new_v4())),
params: TurnStartParams {
thread_id: thread_id.clone(),
collaboration_mode,
input: vec![UserInput::Text {
text: AdditionalContextUserFragment::new("prompt_suggestion".to_string(), format!("{}\n\nReturn only JSON matching this schema: {{\"type\":\"object\",\"properties\":{{\"suggestion\":{{\"type\":[\"string\",\"null\"]}}}},\"required\":[\"suggestion\"],\"additionalProperties\":false}}.", crate::prompt_suggestions::PROMPT)).render(),
text_elements: Vec::new(),
}],
..Default::default()
},
}).await?;
turn_id = Some(response.turn.id.clone());
tokio::select! {
biased;
_ = request.cancellation.cancelled() => Err(eyre!("suggestion cancelled or timed out")),
result = collect_structured_response(receiver, &response.turn.id) => result,
}
}.await;
if result.is_err()
&& let Some(turn_id) = turn_id
{
let _ = tokio::time::timeout(
SUGGESTION_TIMEOUT,
handle.request_typed::<TurnInterruptResponse>(ClientRequest::TurnInterrupt {
request_id: RequestId::String(format!(
"suggestion-interrupt-{}",
Uuid::new_v4()
)),
params: TurnInterruptParams {
thread_id: thread_id.clone(),
turn_id,
},
}),
)
.await;
}
unsubscribe_temporary_thread(&handle, thread_id).await;
let text = result
.ok()
.and_then(|text| crate::prompt_suggestions::parse_suggestion(&text));
request.generation_finished.cancel();
events.send(AppEvent::PromptSuggestionFinished {
request,
temporary_thread_id,
text,
});
});
}
}
#[cfg(test)]
#[path = "prompt_suggestions_tests.rs"]
mod tests;
@@ -1,241 +0,0 @@
//! Tests hidden suggestion forks through the app-server API.
use super::*;
use crate::app::session_lifecycle::ThreadAttachPresentation;
use crate::app::test_support::make_test_app;
use crate::app_event_sender::AppEventSender;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tokio::sync::mpsc::unbounded_channel;
#[derive(Clone, Copy, PartialEq)]
enum Scenario {
Success,
UnchangedSettings,
CancelStartup,
Timeout,
}
#[tokio::test]
async fn prompt_suggestion_fork_preserves_request_prefix_and_stays_hidden() -> color_eyre::Result<()>
{
check_suggestion(Scenario::UnchangedSettings).await?;
check_suggestion(Scenario::Success).await
}
#[tokio::test]
async fn prompt_suggestion_cancelled_startup_cleans_up_without_a_turn() -> color_eyre::Result<()> {
check_suggestion(Scenario::CancelStartup).await
}
#[tokio::test]
async fn prompt_suggestion_timeout_interrupts_and_cleans_up() -> color_eyre::Result<()> {
check_suggestion(Scenario::Timeout).await
}
async fn check_suggestion(scenario: Scenario) -> color_eyre::Result<()> {
let server = wiremock::MockServer::start().await;
let parent_response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("parent"),
responses::ev_assistant_message("answer", "Fixed the timeout."),
responses::ev_completed("parent"),
]),
)
.await;
let codex_home = tempdir()?;
let provider_id = "suggestion-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 = \"Suggestion 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.local_settings.tui.animations = false;
app.local_settings.tui.prompt_suggestions = true;
app.app_event_tx = AppEventSender::new(event_tx);
app.config = ConfigBuilder::default()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(dunce::canonicalize(codex_home.path())?))
.build()
.await?;
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_server
.thread_set_name(thread_id, "Timeout fix".into())
.await?;
app.chat_widget
.on_thread_name_updated(thread_id, Some("Timeout fix".into()));
while event_rx.try_recv().is_ok() {}
let _: TurnStartResponse = app_server
.request_handle()
.request_typed(ClientRequest::TurnStart {
request_id: RequestId::String("parent-turn".into()),
params: TurnStartParams {
thread_id: thread_id.to_string(),
service_tier: (scenario == Scenario::Success).then(|| Some("priority".into())),
collaboration_mode: (scenario == Scenario::Success).then(|| {
codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: "gpt-5.2".into(),
reasoning_effort: Some(
codex_protocol::openai_models::ReasoningEffort::High,
),
developer_instructions: Some("Keep requests concise.".into()),
},
}
}),
input: vec![UserInput::Text {
text: "Fix the timeout".into(),
text_elements: vec![],
}],
..Default::default()
},
})
.await?;
let mut fork_response = None;
let mut hidden_id = None;
let mut interrupted = false;
tokio::time::timeout(Duration::from_secs(/*secs*/ 45), async {
loop {
tokio::select! {
Some(event) = event_rx.recv() => {
if matches!(&event, AppEvent::GeneratePromptSuggestion(_)) {
let mut response = responses::sse_response(responses::sse(vec![
responses::ev_response_created("prediction"),
responses::ev_assistant_message("suggestion", r#"{"suggestion":"Add a regression test"}"#),
responses::ev_completed("prediction"),
]));
if scenario == Scenario::Timeout { response = response.set_delay(Duration::from_secs(/*secs*/ 60)); }
fork_response = Some(responses::mount_response_once(&server, response).await);
}
if let AppEvent::PromptSuggestionStarted { result, request } = &event {
result.as_ref().expect("fork startup");
if scenario == Scenario::CancelStartup { request.cancellation.cancel(); }
}
if let AppEvent::PromptSuggestionFinished { temporary_thread_id, .. } = &event {
hidden_id = Some(*temporary_thread_id);
}
app.handle_event(&mut tui, &mut app_server, event).await?;
}
Some(event) = app_server.next_event() => {
if let AppServerEvent::ServerNotification(notification) = &event {
if let ServerNotification::TurnCompleted(completed) = notification.as_ref()
&& completed.thread_id != thread_id.to_string()
{ interrupted = completed.turn.status == codex_app_server_protocol::TurnStatus::Interrupted; }
if let ServerNotification::TurnStarted(started) = notification.as_ref()
&& started.thread_id != thread_id.to_string() && scenario == Scenario::Timeout
{
tokio::time::pause();
tokio::time::advance(Duration::from_secs(/*secs*/ 31)).await;
tokio::time::resume();
}
}
app.handle_app_server_event(&app_server, event).await;
app.drain_active_thread_events_until(&mut tui, Instant::now() + Duration::from_secs(/*secs*/ 1)).await?;
}
}
if hidden_id.is_some() && (scenario != Scenario::Timeout || interrupted) { break; }
}
color_eyre::eyre::Ok(())
}).await??;
let hidden_id = hidden_id.expect("hidden fork");
assert!(!app.thread_event_channels.contains_key(&hidden_id));
assert!(app.temporary_structured_requests.is_empty());
let detached: codex_app_server_protocol::ThreadUnsubscribeResponse = app_server
.request_handle()
.request_typed(ClientRequest::ThreadUnsubscribe {
request_id: RequestId::String("verify-cleanup".into()),
params: codex_app_server_protocol::ThreadUnsubscribeParams {
thread_id: hidden_id.to_string(),
},
})
.await?;
assert!(matches!(
detached.status,
codex_app_server_protocol::ThreadUnsubscribeStatus::NotSubscribed
| codex_app_server_protocol::ThreadUnsubscribeStatus::NotLoaded
));
if matches!(scenario, Scenario::CancelStartup | Scenario::Timeout) {
if scenario == Scenario::CancelStartup {
assert!(fork_response.as_ref().unwrap().requests().is_empty());
}
assert!(!app.chat_widget.has_prompt_suggestion());
return Ok(());
}
let requests = [
parent_response.single_request(),
fork_response
.expect("generation requested")
.single_request(),
];
if scenario == Scenario::Success {
insta::assert_snapshot!(
"prompt_suggestion_request_history",
context_snapshot::format_request_history_snapshot(
"A completed turn is forked for a hidden follow-up suggestion with the parent's request prefix intact.",
&requests,
&ContextSnapshotOptions::default().rewrite_known_segments(),
)
);
}
let parent = requests[0].body_json();
let fork = requests[1].body_json();
for field in [
"model",
"instructions",
"tools",
"reasoning",
"service_tier",
"text",
"prompt_cache_key",
"stream_options",
] {
assert_eq!(&fork[field], &parent[field], "{field}");
}
let parent_input = parent["input"].as_array().expect("parent input");
let fork_input = fork["input"].as_array().expect("fork input");
assert_eq!(&fork_input[..parent_input.len()], parent_input.as_slice());
assert!(app.chat_widget.composer_is_empty());
assert!(
crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80)
.contains("Add a regression test")
);
Ok(())
}
-1
View File
@@ -175,7 +175,6 @@ impl App {
}
pub(super) fn begin_reconnect(&mut self) -> bool {
self.chat_widget.clear_prompt_suggestion();
if matches!(self.app_server_target, AppServerTarget::Embedded) {
return false;
}
@@ -490,7 +490,6 @@ impl App {
/// This helper copies every known nickname/role from `AgentNavigationState` into the
/// replacement widget so that replayed collab items render agent names immediately.
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
self.chat_widget.clear_prompt_suggestion();
self.pending_right_click_paste = None;
if !self.chat_widget.realtime_conversation_is_running() {
self.retain_realtime_replay_state_before_replace();
@@ -848,7 +847,6 @@ impl App {
.set_queue_submissions_until_session_configured(/*queue*/ false);
match result {
Ok(started) => {
self.chat_widget.prompt_suggestion_summary = started.reasoning_summary;
self.chat_widget.mark_fresh_task_for_sparkle(&started);
let thread_id = started.session.thread_id;
if started.task_tools_available {
@@ -1091,7 +1089,6 @@ impl App {
initial_user_message,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.chat_widget.prompt_suggestion_summary = started.reasoning_summary;
if matches!(
presentation,
ThreadAttachPresentation::Fresh | ThreadAttachPresentation::FreshWithDraft
@@ -1,27 +0,0 @@
---
source: tui/src/app/prompt_suggestions_tests.rs
expression: "context_snapshot::format_request_history_snapshot(\"A completed turn is forked for a hidden follow-up suggestion with the parent's request prefix intact.\",\n&requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)"
---
Scenario: A completed turn is forked for a hidden follow-up suggestion with the parent's request prefix intact.
## Window 1
-- request 1 (turn) --
00:message/developer[3]:
[01] <SKILLS_INSTRUCTIONS>
[02] <PERMISSIONS_INSTRUCTIONS>
[03] <COLLABORATION_MODE>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Fix the timeout
-- request 2 (turn) --
03:message/assistant:
Fixed the timeout.
04:message/user:
<external_prompt_suggestion>This is an app-generated request for a suggested next user message. The user did not write this message.
Your suggestion will appear in the user's empty message composer. They can accept it, edit it, or ignore it; it will not be sent automatically.
Predict this user's next message from the whole conversation. Before choosing a new content question, co...e null only if every concrete next move is arbitrary. [hash=08478ce37447c028]
Return only JSON matching this schema: {"type":"object","properties":{"suggestion":{"type":["string","nu...ionalProperties":false}.</external_prompt_suggestion> [hash=18d5da5f1ee1d605]
-2
View File
@@ -813,7 +813,6 @@ See the Codex keymap documentation for supported actions and examples."
background_voice: None,
background_voice_error: None,
temporary_structured_requests: HashMap::new(),
hidden_prompt_threads: VecDeque::new(),
pending_thread_titles: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
@@ -876,7 +875,6 @@ See the Codex keymap documentation for supported actions and examples."
app.update_visible_history_rows(tui.terminal.last_known_screen_size);
let initial_session_started_at = Instant::now();
if let Some(started) = initial_started_thread {
app.chat_widget.prompt_suggestion_summary = started.reasoning_summary;
let thread_id = started.session.thread_id;
app.chat_widget
.set_task_mentions_enabled(started.task_tools_available);
-1
View File
@@ -84,7 +84,6 @@ pub(crate) async fn make_test_app() -> App {
background_voice: None,
background_voice_error: None,
temporary_structured_requests: HashMap::new(),
hidden_prompt_threads: VecDeque::new(),
pending_thread_titles: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
-6
View File
@@ -630,7 +630,6 @@ async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach()
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
},
session_lifecycle::ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
@@ -5307,7 +5306,6 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() {
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
},
&mut child_snapshot,
)
@@ -6018,7 +6016,6 @@ async fn make_test_app() -> Box<App> {
background_voice: None,
background_voice_error: None,
temporary_structured_requests: HashMap::new(),
hidden_prompt_threads: VecDeque::new(),
pending_thread_titles: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
@@ -6134,7 +6131,6 @@ pub(super) async fn make_test_app_with_channels() -> (
background_voice: None,
background_voice_error: None,
temporary_structured_requests: HashMap::new(),
hidden_prompt_threads: VecDeque::new(),
pending_thread_titles: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
@@ -6463,7 +6459,6 @@ async fn app_server_thread_replacement_clears_previous_transcript_before_replay(
)],
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
},
session_lifecycle::ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
@@ -8830,7 +8825,6 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
turns: resumed_turns.clone(),
blocks_direct_input: true,
task_tools_available: false,
reasoning_summary: None,
},
&mut snapshot,
)
@@ -103,7 +103,6 @@ async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary()
turns: vec![turn],
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
},
&mut snapshot,
)
@@ -16,7 +16,6 @@ fn started(model: &str) -> AppServerStartedThread {
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}
}
-6
View File
@@ -921,7 +921,6 @@ async fn fresh_startup_thread_drains_buffered_approval_before_draft_handoff() ->
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}),
},
))
@@ -1137,7 +1136,6 @@ async fn startup_thread_started_submits_queued_startup_input() {
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}),
)
.await
@@ -1176,7 +1174,6 @@ async fn fresh_startup_notice_follows_session_attachment() {
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}),
)
.await
@@ -1336,7 +1333,6 @@ async fn startup_thread_started_discards_another_threads_buffered_events() {
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}),
)
.await
@@ -1386,7 +1382,6 @@ async fn startup_thread_started_does_not_replay_resolved_approval() -> Result<()
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}),
)
.await?;
@@ -1529,7 +1524,6 @@ fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> {
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
}),
)
.await?;
-1
View File
@@ -122,7 +122,6 @@ impl App {
/// Revert the current thread before the selected prompt.
pub(crate) fn apply_backtrack_selection(&mut self, selection: BacktrackSelection) {
self.chat_widget.clear_prompt_suggestion();
if self.chat_widget.side_conversation_active() {
self.reset_backtrack_state();
self.chat_widget
-12
View File
@@ -61,7 +61,6 @@ use codex_app_server_protocol::AskForApproval;
use codex_config::types::ApprovalsReviewer;
use codex_features::Feature;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::models::ActivePermissionProfile;
use codex_realtime_webrtc::StartedRealtimeWebrtcSession;
@@ -314,17 +313,6 @@ pub(crate) enum AppEvent {
thread_id: ThreadId,
request_id: Uuid,
},
/// Generate a next-message suggestion for one live completed turn.
GeneratePromptSuggestion(crate::prompt_suggestions::SuggestionRequest),
PromptSuggestionStarted {
request: crate::prompt_suggestions::SuggestionRequest,
result: Result<(String, Option<CollaborationMode>), String>,
},
PromptSuggestionFinished {
request: crate::prompt_suggestions::SuggestionRequest,
temporary_thread_id: ThreadId,
text: Option<String>,
},
/// Register a hidden title-generation thread started in the background.
ThreadTitleStarted {
cancellation: CancellationToken,
-11
View File
@@ -364,7 +364,6 @@ impl ThreadParamsMode {
#[derive(Debug, Clone)]
pub(crate) struct AppServerStartedThread {
pub(crate) reasoning_summary: Option<codex_protocol::config_types::ReasoningSummary>,
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
pub(crate) blocks_direct_input: bool,
@@ -2202,14 +2201,6 @@ async fn started_thread_from_start_response(
turns: response.thread.turns,
blocks_direct_input,
task_tools_available: false,
reasoning_summary: match thread_params_mode {
ThreadParamsMode::Embedded => Some(
config
.model_reasoning_summary
.unwrap_or(codex_protocol::config_types::ReasoningSummary::None),
),
ThreadParamsMode::Remote => config.model_reasoning_summary,
},
})
}
@@ -2233,7 +2224,6 @@ async fn started_thread_from_resume_response(
turns: response.thread.turns,
blocks_direct_input,
task_tools_available: false,
reasoning_summary: None,
})
}
@@ -2257,7 +2247,6 @@ async fn started_thread_from_fork_response(
turns: response.thread.turns,
blocks_direct_input,
task_tools_available: false,
reasoning_summary: None,
})
}
@@ -103,7 +103,6 @@ impl AppServerSession {
turns: thread.turns,
blocks_direct_input: false,
task_tools_available: false,
reasoning_summary: None,
},
history_notice,
))
+47 -29
View File
@@ -74,11 +74,6 @@
//! - Local in-session history (full text + text elements + local/remote image attachments).
//!
//! Plain-text history recall strips images and their placeholders.
//!
//! Follow-up suggestions live outside the draft. Empty, focused, editable composers show
//! them dimly; Tab copies one into the draft and a separate Enter submits. Escape cancels
//! even a pending suggestion. Popups, Vim Escape, attachments, and paste bursts take precedence.
//! Interactive transcript footers hide the suggestion and its reserved height until editing resumes.
//! When recalling a persistent entry, encoded task links restore atomic elements and bindings.
//! Recall moves the cursor to the end. Question editors copy primary history on recall/search;
//! draft capture cancels previews, and restoration resets traversal.
@@ -386,13 +381,11 @@ mod draft_state;
mod footer_state;
mod history_search;
mod inline_input;
mod keymap_bindings;
mod mouse;
mod paste_input;
mod popup_state;
mod reconnect;
pub(crate) use reconnect::RestrictedInputMode;
mod prompt_suggestions;
mod slash_input;
mod sparkle;
mod status_surface;
@@ -624,9 +617,6 @@ pub(crate) struct ChatComposer {
luna_reserve_active: bool,
attachments: AttachmentState,
placeholder_text: String,
prompt_suggestion: Option<crate::prompt_suggestions::PromptSuggestion>,
suggestion_tab_reserved: KeymapContextSet,
suggestion_tab_accepted: bool,
blocks_direct_input: bool,
is_task_running: bool,
queue_submissions: bool,
@@ -803,9 +793,6 @@ impl ChatComposer {
luna_reserve_active: false,
attachments: AttachmentState::default(),
placeholder_text,
prompt_suggestion: None,
suggestion_tab_reserved: KeymapContextSet::default(),
suggestion_tab_accepted: false,
blocks_direct_input: false,
is_task_running: false,
queue_submissions: false,
@@ -1040,6 +1027,51 @@ impl ChatComposer {
self.app_event_tx = app_event_tx;
}
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
///
/// Submit and queue bindings are cached here because composer dispatch must
/// check them before generic textarea editing. The embedded textarea receives
/// the same snapshot's editor bindings so a live remap cannot leave submit
/// keys updated while cursor/editing keys still use old defaults.
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.submit_keys = keymap.composer.submit.clone();
self.queue_keys = keymap.composer.queue.clone();
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
self.history_search_previous_keys = keymap.composer.history_search_previous.clone();
self.history_search_next_keys = keymap.composer.history_search_next.clone();
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.draft.textarea.set_keymap_bindings(keymap);
self.footer.external_editor_key =
keymap.primary_hint(KeymapContext::Global, "open_external_editor");
self.footer.show_warnings_key = keymap.primary_hint(KeymapContext::Global, "open_warnings");
self.footer.show_transcript_key =
keymap.primary_hint(KeymapContext::Global, "open_transcript");
self.footer.find_transcript_key =
keymap.primary_hint(KeymapContext::Global, "find_transcript");
self.footer.focus_activity_key =
keymap.primary_hint(KeymapContext::Global, "focus_activity");
self.footer.insert_newline_key =
match keymap.primary_hint(KeymapContext::Editor, "insert_newline") {
hint @ Some(ShortcutHint::Chord { .. }) => hint,
_ => footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
)
.map(ShortcutHint::from),
};
self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue");
self.footer.toggle_shortcuts_key =
keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts");
self.footer.history_search_key =
keymap.primary_hint(KeymapContext::Composer, "history_search_previous");
self.footer.reasoning_down_key =
keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort");
self.footer.reasoning_up_key =
keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort");
self.footer.toggle_voice_key = keymap.primary_hint(KeymapContext::Chat, "toggle_voice");
}
/// Return the contexts whose handlers can consume the next composer key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if self.draft.textarea.vim_query().is_some() {
@@ -1922,12 +1954,6 @@ impl ChatComposer {
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
self.suggestion_tab_accepted &= key_event.code == KeyCode::Tab
&& key_event.modifiers == KeyModifiers::NONE
&& key_event.kind != KeyEventKind::Release;
if self.suggestion_tab_accepted {
return (InputResult::None, false);
}
if !self.draft.input_enabled {
return (InputResult::None, false);
}
@@ -3483,9 +3509,6 @@ impl ChatComposer {
self.draft.textarea.enter_vim_insert_mode();
return (InputResult::None, true);
}
if self.handle_prompt_suggestion_key(key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
if self.is_empty() {
let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);
@@ -4349,9 +4372,6 @@ impl ChatComposer {
}
pub fn set_task_running(&mut self, running: bool) {
if running {
self.clear_prompt_suggestion();
}
self.is_task_running = running;
}
@@ -4974,11 +4994,9 @@ impl ChatComposer {
}
}
}
if let Some(lines) = self.prompt_suggestion_lines(textarea_rect.width, options) {
Paragraph::new(lines).render(textarea_rect, buf);
} else if !self.draft.input_enabled || textarea_is_empty {
if !self.draft.input_enabled || textarea_is_empty {
let text = if self.draft.input_enabled {
self.placeholder_text.to_string()
self.placeholder_text.as_str().to_string()
} else {
self.draft
.input_disabled_placeholder
@@ -183,11 +183,7 @@ impl ChatComposer {
.try_into()
.unwrap_or(u16::MAX);
let remote_images_separator = u16::from(remote_images_height > 0);
self.prompt_suggestion_lines(inner_width, options)
.map_or_else(
|| self.draft.textarea.desired_height(inner_width),
|lines| u16::try_from(lines.len()).unwrap_or(u16::MAX),
)
self.draft.textarea.desired_height(inner_width)
+ remote_images_height
+ remote_images_separator
+ self.status_surface_height(options)
@@ -104,40 +104,6 @@ fn retained_command_suggestions_yield_paint_and_cursor_to_find() {
assert_eq!(render(&composer, /*footer*/ None), original);
}
#[test]
fn prompt_suggestion_yields_space_to_transcript_find() {
let mut composer = composer();
let footer = TranscriptFooter {
text: vec![
Line::from("Find: needle"),
Line::from("Enter next · Esc close"),
]
.into(),
cursor_column: Some(8),
is_interactive: true,
};
let without_suggestion = render(&composer, Some(&footer));
let request = crate::prompt_suggestions::SuggestionRequest {
thread_id: codex_protocol::ThreadId::new(),
turn_id: "turn".into(),
summary: codex_protocol::config_types::ReasoningSummary::None,
id: uuid::Uuid::new_v4(),
cancellation: tokio_util::sync::CancellationToken::new(),
generation_finished: tokio_util::sync::CancellationToken::new(),
};
composer.set_prompt_suggestion(request.clone());
composer.apply_prompt_suggestion(
&request,
Some("Add a regression test for the timeout and explain how the cleanup works".into()),
);
let original = render(&composer, /*footer*/ None);
let during_search = render(&composer, Some(&footer));
assert_eq!(during_search, without_suggestion);
insta::assert_snapshot!("find_hides_prompt_suggestion", text(&during_search.0));
assert_eq!(render(&composer, /*footer*/ None), original);
assert!(!request.cancellation.is_cancelled());
}
#[test]
fn copying_from_find_preserves_the_query_above_selection_feedback() {
let mut composer = composer();
@@ -1,66 +0,0 @@
//! Apply key bindings together so editing, submission, and suggestion acceptance agree.
use super::*;
impl ChatComposer {
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
///
/// Submit and queue bindings are cached here because composer dispatch must
/// check them before generic textarea editing. The embedded textarea receives
/// the same snapshot's editor bindings so a live remap cannot leave submit
/// keys updated while cursor/editing keys still use old defaults.
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.suggestion_tab_reserved = crate::keymap::keymap_action_ids()
.filter(|action| {
matches!(
action.context,
KeymapContext::Editor | KeymapContext::VimNormal | KeymapContext::Composer
) && !(action.context == KeymapContext::Composer && action.action == "queue")
&& crate::keymap::bindings_for_action(
keymap,
action.context.config_name(),
action.action,
)
.is_some_and(|bindings| bindings.is_pressed(KeyCode::Tab.into()))
})
.fold(KeymapContextSet::default(), |contexts, action| {
contexts.with(action.context)
});
self.submit_keys = keymap.composer.submit.clone();
self.queue_keys = keymap.composer.queue.clone();
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
self.history_search_previous_keys = keymap.composer.history_search_previous.clone();
self.history_search_next_keys = keymap.composer.history_search_next.clone();
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.draft.textarea.set_keymap_bindings(keymap);
self.footer.external_editor_key =
keymap.primary_hint(KeymapContext::Global, "open_external_editor");
self.footer.show_warnings_key = keymap.primary_hint(KeymapContext::Global, "open_warnings");
self.footer.show_transcript_key =
keymap.primary_hint(KeymapContext::Global, "open_transcript");
self.footer.find_transcript_key =
keymap.primary_hint(KeymapContext::Global, "find_transcript");
self.footer.focus_activity_key =
keymap.primary_hint(KeymapContext::Global, "focus_activity");
self.footer.insert_newline_key =
match keymap.primary_hint(KeymapContext::Editor, "insert_newline") {
hint @ Some(ShortcutHint::Chord { .. }) => hint,
_ => footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
)
.map(ShortcutHint::from),
};
self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue");
self.footer.toggle_shortcuts_key =
keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts");
self.footer.history_search_key =
keymap.primary_hint(KeymapContext::Composer, "history_search_previous");
self.footer.reasoning_down_key =
keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort");
self.footer.reasoning_up_key =
keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort");
self.footer.toggle_voice_key = keymap.primary_hint(KeymapContext::Chat, "toggle_voice");
}
}
@@ -1,107 +0,0 @@
//! Keep suggestions outside the draft until Tab accepts them. Typing hides them,
//! Escape cancels them, and stale or empty results cannot capture input.
//! Transcript interactions hide suggestion text and its reserved height without cancelling it.
use super::*;
use crate::prompt_suggestions::PromptSuggestion;
use crate::prompt_suggestions::SuggestionRequest;
impl ChatComposer {
pub(crate) fn set_prompt_suggestion(&mut self, request: SuggestionRequest) {
self.prompt_suggestion = Some(PromptSuggestion {
request,
text: None,
});
}
pub(crate) fn has_prompt_suggestion(&self) -> bool {
self.prompt_suggestion
.as_ref()
.is_some_and(|suggestion| !suggestion.request.cancellation.is_cancelled())
}
pub(crate) fn clear_prompt_suggestion(&mut self) {
self.prompt_suggestion = None;
}
pub(crate) fn apply_prompt_suggestion(
&mut self,
request: &SuggestionRequest,
text: Option<String>,
) {
if let Some(suggestion) = &mut self.prompt_suggestion
&& suggestion.request.id == request.id
&& !request.cancellation.is_cancelled()
{
suggestion.text = text;
if suggestion.text.is_none() {
self.clear_prompt_suggestion();
}
}
}
pub(super) fn visible_prompt_suggestion(&self) -> Option<&str> {
let suggestion = self.prompt_suggestion.as_ref()?;
(self.has_focus
&& self.sparkle.terminal_focused
&& self.draft.input_enabled
&& !self.blocks_direct_input
&& !self.is_task_running
&& !self.queue_submissions
&& self.is_empty()
&& !self.popup_active()
&& !self.is_in_paste_burst()
&& !suggestion.request.cancellation.is_cancelled())
.then_some(suggestion.text.as_deref())
.flatten()
}
pub(super) fn prompt_suggestion_lines(
&self,
width: u16,
options: ComposerRenderOptions<'_>,
) -> Option<Vec<Line<'static>>> {
if options.footer.is_some_and(|footer| footer.is_interactive) {
return None;
}
Some(
textwrap::wrap(self.visible_prompt_suggestion()?, usize::from(width.max(1)))
.into_iter()
.map(|line| Line::from(line.into_owned().dim()))
.collect(),
)
}
pub(super) fn handle_prompt_suggestion_key(&mut self, key: KeyEvent) -> bool {
if key.modifiers != KeyModifiers::NONE {
return false;
}
if key.code == KeyCode::Esc && self.has_prompt_suggestion() {
self.clear_prompt_suggestion();
return true;
}
if key.code == KeyCode::Tab
&& ![
KeymapContext::Editor,
KeymapContext::VimNormal,
KeymapContext::Composer,
]
.into_iter()
.any(|context| {
self.suggestion_tab_reserved.contains(context)
&& self.keymap_contexts().contains(context)
})
&& let Some(text) = self.visible_prompt_suggestion().map(str::to_owned)
{
self.clear_prompt_suggestion();
self.insert_str(&text);
self.suggestion_tab_accepted = true;
return true;
}
false
}
}
#[cfg(test)]
#[path = "prompt_suggestions_tests.rs"]
mod tests;
@@ -1,151 +0,0 @@
//! Exercise suggestion parsing and composer input behavior.
use super::super::tests::new_test_composer;
use super::super::tests::snapshot_composer_state_with_width;
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
fn request() -> SuggestionRequest {
SuggestionRequest {
thread_id: ThreadId::new(),
turn_id: "turn".into(),
summary: codex_protocol::config_types::ReasoningSummary::None,
id: Uuid::new_v4(),
cancellation: CancellationToken::new(),
generation_finished: CancellationToken::new(),
}
}
#[test]
fn prompt_suggestion_tab_edits_and_enter_submits() {
let (mut composer, _rx) = new_test_composer();
composer.set_vim_enabled(/*enabled*/ true);
composer.handle_key_event(KeyCode::Esc.into());
let request = request();
composer.set_prompt_suggestion(request.clone());
composer.apply_prompt_suggestion(&request, Some("Add a regression test".into()));
assert!(composer.is_empty());
let (result, _) = composer.handle_key_event(KeyCode::Tab.into());
assert!(matches!(result, InputResult::None));
assert_eq!(composer.draft.textarea.text(), "Add a regression test");
assert!(request.cancellation.is_cancelled());
for kind in [KeyEventKind::Press, KeyEventKind::Repeat] {
let (result, _) = composer.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Tab,
KeyModifiers::NONE,
kind,
));
assert!(matches!(result, InputResult::None));
assert_eq!(composer.draft.textarea.text(), "Add a regression test");
}
composer.handle_key_event(KeyCode::Char('u').into());
assert!(composer.is_empty());
composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert_eq!(composer.draft.textarea.text(), "Add a regression test");
let (result, _) = composer.handle_key_event(KeyCode::Enter.into());
assert!(
matches!(result, InputResult::Submitted { text, .. } if text == "Add a regression test")
);
}
#[test]
fn prompt_suggestion_dismissal_and_stale_result() {
let (mut composer, _rx) = new_test_composer();
let old = request();
composer.set_prompt_suggestion(old.clone());
composer.handle_key_event(KeyCode::Esc.into());
assert!(old.cancellation.is_cancelled());
composer.apply_prompt_suggestion(&old, Some("Late".into()));
assert_eq!(composer.visible_prompt_suggestion(), None);
let next = request();
composer.set_prompt_suggestion(next.clone());
composer.apply_prompt_suggestion(&next, Some("Next".into()));
composer.apply_prompt_suggestion(&old, Some("Stale".into()));
assert_eq!(composer.visible_prompt_suggestion(), Some("Next"));
composer.set_task_running(/*running*/ true);
assert!(next.cancellation.is_cancelled());
composer.set_prompt_suggestion(request());
let empty = composer.prompt_suggestion.as_ref().unwrap().request.clone();
composer.apply_prompt_suggestion(&empty, /*text*/ None);
assert!(!composer.has_prompt_suggestion());
}
#[test]
fn prompt_suggestion_hides_for_typing_and_input_owners() {
let (mut composer, _rx) = new_test_composer();
let request = request();
composer.set_prompt_suggestion(request.clone());
composer.handle_key_event(KeyCode::Char('a').into());
composer.apply_prompt_suggestion(&request, Some("Continue".into()));
assert_eq!(composer.visible_prompt_suggestion(), None);
composer.handle_key_event(KeyCode::Tab.into());
assert!(!composer.draft.textarea.text().contains("Continue"));
composer.draft.paste_burst.clear_after_explicit_paste();
composer.draft.textarea.set_text_clearing_elements("");
assert_eq!(composer.visible_prompt_suggestion(), Some("Continue"));
composer
.attachments
.remote_image_urls
.push("https://example.test/image.png".into());
assert_eq!(composer.visible_prompt_suggestion(), None);
}
#[test]
fn prompt_suggestion_snapshots() {
snapshot_composer_state_with_width(
"prompt_suggestion_24",
/*width*/ 24,
/*enhanced_keys_supported*/ false,
|composer| {
let request = request();
composer.set_prompt_suggestion(request.clone());
composer.apply_prompt_suggestion(
&request,
Some("Add a regression test for the timeout".into()),
);
},
);
}
#[test]
fn prompt_suggestion_yields_to_remapped_tab_and_popup() {
let (mut composer, _rx) = new_test_composer();
let request = request();
composer.set_prompt_suggestion(request.clone());
composer.apply_prompt_suggestion(&request, Some("Continue".into()));
let mut config = codex_config::types::TuiKeymap::default();
config.composer.submit = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("tab".into()),
));
config.composer.queue = Some(codex_config::types::KeybindingsSpec::Many(vec![]));
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("keymap");
composer.set_keymap_bindings(&keymap);
composer.handle_key_event(KeyCode::Tab.into());
assert!(composer.is_empty());
assert!(!request.cancellation.is_cancelled());
composer.handle_paste("/".into());
assert!(composer.popup_active());
composer.handle_key_event(KeyCode::Esc.into());
assert!(!request.cancellation.is_cancelled());
}
#[test]
fn prompt_suggestion_tab_only_yields_to_active_vim_bindings() {
let (mut composer, _rx) = new_test_composer();
composer.set_vim_enabled(/*enabled*/ true);
composer.handle_key_event(KeyCode::Esc.into());
let request = request();
composer.set_prompt_suggestion(request.clone());
composer.apply_prompt_suggestion(&request, Some("Continue".into()));
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.vim_normal.undo = vec![crate::key_hint::plain(KeyCode::Tab)];
composer.set_keymap_bindings(&keymap);
composer.handle_key_event(KeyCode::Tab.into());
assert!(composer.is_empty());
composer.handle_key_event(KeyCode::Char('i').into());
composer.handle_key_event(KeyCode::Tab.into());
assert_eq!(composer.current_text(), "Continue");
}
@@ -1,10 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs
assertion_line: 136
expression: text(&during_search.0)
---
› Ask Codex
Find: needle
Enter next · Esc close
@@ -67,7 +67,7 @@ pub(super) struct Sparkle {
command_input: RefCell<String>,
phase: Cell<Phase>,
fresh: bool,
pub(super) terminal_focused: bool,
terminal_focused: bool,
}
impl Default for Sparkle {
-24
View File
@@ -1306,30 +1306,6 @@ impl BottomPane {
// esc_backtrack_hint_visible removed; hints are controlled internally.
pub(crate) fn set_prompt_suggestion(
&mut self,
request: crate::prompt_suggestions::SuggestionRequest,
) {
self.composer.set_prompt_suggestion(request);
}
pub(crate) fn has_prompt_suggestion(&self) -> bool {
self.composer.has_prompt_suggestion()
}
pub(crate) fn clear_prompt_suggestion(&mut self) {
self.composer.clear_prompt_suggestion();
}
pub(crate) fn apply_prompt_suggestion(
&mut self,
request: &crate::prompt_suggestions::SuggestionRequest,
text: Option<String>,
) {
self.composer.apply_prompt_suggestion(request, text);
self.request_redraw();
}
pub fn set_task_running(&mut self, running: bool) {
let was_running = self.is_task_running;
self.is_task_running = running;
@@ -1,14 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5274
expression: terminal.backend()
---
" "
"› Add a regression test "
" for the timeout "
" "
" "
" "
" "
" "
" 100% context left "
-4
View File
@@ -287,7 +287,6 @@ mod input_flow;
mod input_restore;
mod input_submission;
mod interrupts;
mod prompt_suggestions;
mod questions;
mod startup_submission;
use self::interrupts::InterruptManager;
@@ -657,8 +656,6 @@ pub(crate) struct ChatWidget {
pet_image_support_override: Option<crate::pets::PetImageSupport>,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
// Unknown until the server supplies live settings; resume responses omit summary.
pub(crate) prompt_suggestion_summary: Option<codex_protocol::config_types::ReasoningSummary>,
thread_rename_block_message: Option<String>,
active_side_conversation: bool,
blocks_direct_input: bool,
@@ -1774,7 +1771,6 @@ impl ChatWidget {
}
pub(crate) fn show_external_writer_thread(&mut self) {
self.clear_prompt_suggestion();
self.cancel_image_submission();
self.blocks_direct_input = true;
self.external_writer_view = true;
@@ -223,7 +223,6 @@ impl ChatWidget {
pet_image_support_override: None,
thread_id: None,
thread_name: None,
prompt_suggestion_summary: None,
thread_rename_block_message: None,
active_side_conversation: false,
blocks_direct_input: false,
@@ -23,9 +23,6 @@ impl ChatWidget {
input_result: InputResult,
had_modal_or_popup: bool,
) {
if !matches!(input_result, InputResult::None) {
self.clear_prompt_suggestion();
}
let follow_transcript = match &input_result {
// Opening settings is not a request to leave the current reading anchor.
// Inline commands still follow so their output (including usage errors) is visible.
@@ -76,7 +76,6 @@ impl ChatWidget {
}
pub(super) fn submit_user_message(&mut self, user_message: UserMessage) {
self.clear_prompt_suggestion();
let _accepted = self.submit_user_message_with_history_record(
user_message,
UserMessageHistoryRecord::UserMessageText,
@@ -1,60 +0,0 @@
//! Suggests after successful live turns with known settings. The composer owns cancellation;
//! request identity prevents late results from replacing a newer suggestion.
use super::*;
use crate::prompt_suggestions::SuggestionRequest;
use tokio_util::sync::CancellationToken;
impl ChatWidget {
pub(crate) fn has_prompt_suggestion(&self) -> bool {
self.bottom_pane.has_prompt_suggestion()
}
pub(crate) fn clear_prompt_suggestion(&mut self) {
self.bottom_pane.clear_prompt_suggestion();
}
pub(super) fn suggest_next_prompt(&mut self, turn_id: String) {
if !self.local_settings.tui.prompt_suggestions
|| self.is_user_turn_pending_or_running()
|| self.external_writer_view
|| self.blocks_direct_input
|| self.side_conversation_active()
|| self.has_queued_follow_up_messages()
{
return;
}
let Some(thread_id) = self.thread_id() else {
return;
};
let Some(summary) = self.prompt_suggestion_summary else {
return;
};
let request = SuggestionRequest {
thread_id,
turn_id,
summary,
id: uuid::Uuid::new_v4(),
cancellation: CancellationToken::new(),
generation_finished: CancellationToken::new(),
};
self.bottom_pane.set_prompt_suggestion(request.clone());
self.app_event_tx
.send(AppEvent::GeneratePromptSuggestion(request));
}
pub(crate) fn apply_prompt_suggestion(
&mut self,
request: &SuggestionRequest,
text: Option<String>,
) {
if self.thread_id() == Some(request.thread_id) && self.local_settings.tui.prompt_suggestions
{
self.bottom_pane.apply_prompt_suggestion(request, text);
}
}
}
#[cfg(test)]
#[path = "prompt_suggestions_tests.rs"]
mod tests;
@@ -1,59 +0,0 @@
//! Tests follow-up suggestion eligibility and deduplication.
use super::*;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStartedNotification;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn prompt_suggestion_only_follows_enabled_successful_live_turns() {
for (enabled, status, replay, expected) in [
(false, TurnStatus::Completed, None, 0),
(true, TurnStatus::Failed, None, 0),
(true, TurnStatus::Interrupted, None, 0),
(
true,
TurnStatus::Completed,
Some(ReplayKind::ResumeInitialMessages),
0,
),
(true, TurnStatus::Completed, None, 1),
] {
let (mut chat, _, mut events, _) =
super::super::tests::helpers::make_chatwidget_manual_with_sender().await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.local_settings.tui.prompt_suggestions = enabled;
chat.prompt_suggestion_summary = Some(codex_protocol::config_types::ReasoningSummary::None);
let turn = Turn {
id: "turn".into(),
items: vec![],
items_view: Default::default(),
status: TurnStatus::InProgress,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
};
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: turn.clone(),
}),
replay,
);
let completed = ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: Turn { status, ..turn },
});
chat.handle_server_notification(completed.clone(), replay);
chat.handle_server_notification(completed, replay);
let mut requests = 0;
while let Ok(event) = events.try_recv() {
if matches!(event, AppEvent::GeneratePromptSuggestion(_)) {
requests += 1;
}
}
assert_eq!(requests, expected);
}
}
-8
View File
@@ -73,7 +73,6 @@ impl ChatWidget {
self.on_thread_settings_updated(notification);
}
ServerNotification::TurnStarted(notification) => {
self.clear_prompt_suggestion();
if from_replay {
self.restore_realtime_transcripts_before_turn(&notification.turn.id);
} else {
@@ -408,10 +407,6 @@ impl ChatWidget {
// User-message dedupe only suppresses the app-server echo of a prompt
// this TUI already rendered locally. Once that turn ends, another
// client can submit the same text and it still needs its own user cell.
let suggest = replay_kind.is_none()
&& notification.turn.status == TurnStatus::Completed
&& self.turn_lifecycle.agent_turn_running
&& self.turn_lifecycle.last_turn_id.as_deref() == Some(notification.turn.id.as_str());
self.last_rendered_user_message_display = None;
let mut question_drafts = None;
let was_replaying_turn_completion = self.thread_usage.replaying_turn_completion;
@@ -527,9 +522,6 @@ impl ChatWidget {
self.finish_realtime_turn(&notification.turn.id);
}
self.thread_usage.replaying_turn_completion = was_replaying_turn_completion;
if suggest {
self.suggest_next_prompt(notification.turn.id);
}
}
fn handle_item_started_notification(
-1
View File
@@ -444,7 +444,6 @@ impl ChatWidget {
}
fn apply_thread_settings(&mut self, mut settings: ThreadSettings) {
self.prompt_suggestion_summary = settings.summary;
self.invalidate_permission_discovery();
let cwd_changed = self.config.cwd != settings.cwd;
self.apply_thread_settings_cwd(settings.cwd.clone());
-1
View File
@@ -183,7 +183,6 @@ mod npm_registry;
pub(crate) mod onboarding;
mod oss_selection;
mod pager_overlay;
mod prompt_suggestions;
pub(crate) mod public_widgets;
mod render;
mod resize_reflow_cap;
-1
View File
@@ -70,7 +70,6 @@ impl LocalSettings {
show_tooltips: config.show_tooltips,
show_server_version_notice: config.tui_show_server_version_notice,
auto_recap: config.tui_auto_recap,
prompt_suggestions: config.tui_prompt_suggestions,
disable_paste_burst: Some(config.disable_paste_burst),
vim_mode_default: config.tui_vim_mode_default,
question_esc_back: config.tui_question_esc_back,
-51
View File
@@ -1,51 +0,0 @@
//! A suggestion belongs to one live completed turn and never becomes draft text implicitly.
//! The composer owns cancellation; dropping it also cancels requests during thread switches.
use codex_protocol::ThreadId;
use serde::Deserialize;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
// Desktop FALLBACK_PROMPT, fallback-1. Keep the source text unchanged.
pub(crate) const PROMPT: &str = include_str!("../assets/prompt_suggestion.txt");
#[derive(Clone, Debug)]
pub(crate) struct SuggestionRequest {
pub(crate) thread_id: ThreadId,
pub(crate) turn_id: String,
pub(crate) summary: codex_protocol::config_types::ReasoningSummary,
pub(crate) id: Uuid,
pub(crate) cancellation: CancellationToken,
pub(crate) generation_finished: CancellationToken,
}
pub(crate) struct PromptSuggestion {
pub(crate) request: SuggestionRequest,
pub(crate) text: Option<String>,
}
impl Drop for PromptSuggestion {
fn drop(&mut self) {
self.request.cancellation.cancel();
}
}
pub(crate) fn parse_suggestion(response: &str) -> Option<String> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Response {
suggestion: serde_json::Value,
}
let response = serde_json::from_str::<Response>(response).ok()?;
let text = response.suggestion.as_str()?.trim();
(!text.is_empty()
&& text.chars().count() <= 240
&& !text
.chars()
.any(|ch| ch.is_control() || matches!(ch, '\u{2028}' | '\u{2029}')))
.then(|| text.to_string())
}
#[cfg(test)]
#[path = "prompt_suggestions_tests.rs"]
mod tests;
@@ -1,28 +0,0 @@
//! Exercise suggestion parsing and composer input behavior.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn prompt_suggestion_requires_bounded_single_line_json() {
for response in [
"null",
"{}",
r#"{"suggestion":null}"#,
r#"{"suggestion":3}"#,
r#"{"suggestion":""}"#,
r#"{"suggestion":"a\nb"}"#,
r#"{"suggestion":"ok","extra":true}"#,
] {
assert_eq!(parse_suggestion(response), None, "{response}");
}
for length in [240, 241] {
let text = "é".repeat(length);
let response = serde_json::json!({"suggestion":text}).to_string();
assert_eq!(parse_suggestion(&response), (length == 240).then_some(text));
}
assert_eq!(
parse_suggestion(r#"{"suggestion":" Add a regression test "}"#),
Some("Add a regression test".into())
);
}