Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ mod event_dispatch;
mod history_ui;
mod input;
mod loaded_threads;
mod next_prompt_suggestion;
mod pending_interactive_replay;
mod pets;
mod platform_actions;
Expand Down Expand Up @@ -551,6 +552,10 @@ pub(crate) struct App {
pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests,
pending_startup_thread_start: bool,
/// Monotonic token used to ignore async suggestion results from older UI state.
next_prompt_suggestion_generation: u64,
/// Current fire-and-forget suggestion request, if one is still in flight.
pending_next_prompt_suggestion: Option<PendingNextPromptSuggestion>,
// Serialize plugin enablement writes per plugin so stale completions cannot
// overwrite a newer toggle, even if the plugin is toggled from different
// cwd contexts.
Expand All @@ -577,6 +582,17 @@ impl RuntimePermissionProfileOverride {
}
}

struct PendingNextPromptSuggestion {
task: JoinHandle<()>,
cancel_request: Option<NextPromptSuggestionCancelRequest>,
}

struct NextPromptSuggestionCancelRequest {
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
cancellation_token: String,
}

fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
let TypedRequestError::Server { source, .. } = error else {
return None;
Expand Down Expand Up @@ -1007,6 +1023,8 @@ See the Codex keymap documentation for supported actions and examples."
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start,
next_prompt_suggestion_generation: 0,
pending_next_prompt_suggestion: None,
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
};
Expand All @@ -1018,6 +1036,7 @@ See the Codex keymap documentation for supported actions and examples."
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app.request_next_prompt_suggestion_for_thread(&app_server, thread_id);
if should_prompt_for_paused_goal_after_startup_resume {
app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id)
.await;
Expand Down Expand Up @@ -1222,6 +1241,7 @@ See the Codex keymap documentation for supported actions and examples."
self.handle_key_event(tui, app_server, key_event).await;
}
TuiEvent::Paste(pasted) => {
self.cancel_pending_next_prompt_suggestion();
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
Expand Down Expand Up @@ -1326,6 +1346,7 @@ See the Codex keymap documentation for supported actions and examples."

impl Drop for App {
fn drop(&mut self) {
self.cancel_pending_next_prompt_suggestion();
if let Err(err) = self.chat_widget.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title on app drop");
}
Expand Down
47 changes: 47 additions & 0 deletions codex-rs/tui/src/app/background_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::ThreadSuggestNextPromptParams;
use codex_app_server_protocol::ThreadSuggestNextPromptResponse;

use codex_app_server_protocol::RequestId;

Expand All @@ -23,6 +25,51 @@ use crate::hooks_rpc::write_hook_trust;
use crate::hooks_rpc::write_hook_trusts;
use codex_utils_absolute_path::AbsolutePathBuf;

/// Requests one best-effort next-prompt suggestion from app-server.
///
/// The caller owns cancellation and stale-result suppression. This helper only
/// performs the typed RPC and returns the already-filtered optional text.
pub(super) async fn fetch_next_prompt_suggestion(
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
cancellation_token: String,
) -> Result<Option<String>> {
let request_id = RequestId::String(format!("next-prompt-suggestion-{}", Uuid::new_v4()));
let response: ThreadSuggestNextPromptResponse = request_handle
.request_typed(ClientRequest::ThreadSuggestNextPrompt {
request_id,
params: ThreadSuggestNextPromptParams {
thread_id: thread_id.to_string(),
cancellation_token: Some(cancellation_token),
cancel: None,
},
})
.await
.wrap_err("thread/suggestNextPrompt failed in TUI")?;
Ok(response.suggestion)
}

/// Cancels one in-flight best-effort next-prompt suggestion request.
pub(super) async fn cancel_next_prompt_suggestion(
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
cancellation_token: String,
) -> Result<()> {
let request_id = RequestId::String(format!("next-prompt-suggestion-cancel-{}", Uuid::new_v4()));
let _response: ThreadSuggestNextPromptResponse = request_handle
.request_typed(ClientRequest::ThreadSuggestNextPrompt {
request_id,
params: ThreadSuggestNextPromptParams {
thread_id: thread_id.to_string(),
cancellation_token: Some(cancellation_token),
cancel: Some(/*cancel*/ true),
},
})
.await
.wrap_err("thread/suggestNextPrompt cancellation failed in TUI")?;
Ok(())
}

impl App {
pub(super) fn fetch_mcp_inventory(
&mut self,
Expand Down
9 changes: 9 additions & 0 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ impl App {
self.handle_startup_thread_started(app_server, result)
.await?;
}
AppEvent::NextPromptSuggestionReady {
generation,
thread_id,
latency_ms,
result,
} => {
self.handle_next_prompt_suggestion_ready(generation, thread_id, latency_ms, result);
tui.frame_requester().schedule_frame();
}
AppEvent::ClearUi => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
Expand Down
11 changes: 11 additions & 0 deletions codex-rs/tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ impl App {
app_server: &mut AppServerSession,
key_event: KeyEvent,
) {
if self.next_prompt_suggestion_key_should_accept(key_event) {
self.accept_next_prompt_suggestion();
return;
Comment thread
fcoury-oai marked this conversation as resolved.
}
if matches!(
key_event.code,
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete
) {
self.cancel_pending_next_prompt_suggestion();
}

// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
// agent-switch shortcuts when the composer is empty so we never steal the expected
Expand Down
185 changes: 185 additions & 0 deletions codex-rs/tui/src/app/next_prompt_suggestion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! Owns asynchronous next-prompt suggestion state for the active TUI thread.
//!
//! `App` starts one fire-and-forget RPC after stable conversation boundaries,
//! tags it with a generation number, and forwards only the newest result for the
//! still-visible thread into `ChatWidget`. Typing, paste, turn transitions,
//! backtrack, and thread replacement invalidate pending work so a late response
//! cannot overwrite newer composer state.

use super::*;

impl App {
/// Clears both pending generation work and the visible ghost-text candidate.
pub(crate) fn clear_next_prompt_suggestion(&mut self) {
self.cancel_pending_next_prompt_suggestion();
self.chat_widget.clear_next_prompt_suggestion();
}

/// Starts a suggestion request for the currently displayed primary thread.
///
/// This is the normal post-turn entry point. Callers that already know the
/// attached thread id, such as resume/fork lifecycle code, should use
/// `request_next_prompt_suggestion_for_thread` so transient display state does
/// not suppress the initial request.
pub(super) fn request_next_prompt_suggestion(&mut self, app_server: &AppServerSession) {
let Some(thread_id) = self.current_displayed_thread_id() else {
tracing::debug!("skipping next prompt suggestion without displayed thread");
return;
};
self.request_next_prompt_suggestion_for_thread(app_server, thread_id);
}

/// Starts a suggestion request for `thread_id` and invalidates older requests.
///
/// Side conversations deliberately stay silent because their composer has a
/// different placeholder contract. Starting a new request clears the current
/// visible candidate so the user never accepts text predicted from an older
/// completed boundary.
pub(super) fn request_next_prompt_suggestion_for_thread(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
) {
if self.chat_widget.side_conversation_active() {
tracing::debug!(%thread_id, "skipping next prompt suggestion for side conversation");
return;
}
if !self.chat_widget.can_show_next_prompt_suggestion() {
tracing::debug!(%thread_id, "skipping next prompt suggestion while composer is unavailable");
return;
}

self.cancel_pending_next_prompt_suggestion();
self.chat_widget.clear_next_prompt_suggestion();
self.next_prompt_suggestion_generation = self
.next_prompt_suggestion_generation
.saturating_add(/*rhs*/ 1);
let generation = self.next_prompt_suggestion_generation;
let request_handle = app_server.request_handle();
let cancellation_token = format!("next-prompt-suggestion-{}", Uuid::new_v4());
let cancel_request = NextPromptSuggestionCancelRequest {
request_handle: request_handle.clone(),
thread_id,
cancellation_token: cancellation_token.clone(),
};
let app_event_tx = self.app_event_tx.clone();
let task = tokio::spawn(async move {
let requested_at = Instant::now();
let result = super::background_requests::fetch_next_prompt_suggestion(
request_handle,
thread_id,
cancellation_token,
)
.await
.map_err(|err| format!("{err:#}"));
app_event_tx.send(AppEvent::NextPromptSuggestionReady {
generation,
thread_id,
latency_ms: u64::try_from(requested_at.elapsed().as_millis()).unwrap_or(u64::MAX),
result,
});
});
self.pending_next_prompt_suggestion = Some(PendingNextPromptSuggestion {
task,
cancel_request: Some(cancel_request),
});
}

/// Applies a completed request only when it still matches current UI state.
///
/// Both the generation token and displayed thread must match. Ignoring stale
/// results here is the last guard against a slow background request replacing
/// a newer suggestion after the user typed, switched threads, or resumed a
/// different session.
pub(super) fn handle_next_prompt_suggestion_ready(
&mut self,
generation: u64,
thread_id: ThreadId,
latency_ms: u64,
result: Result<Option<String>, String>,
) {
if generation != self.next_prompt_suggestion_generation
|| self.current_displayed_thread_id() != Some(thread_id)
{
return;
}
self.pending_next_prompt_suggestion = None;
if !self.chat_widget.can_show_next_prompt_suggestion() {
tracing::debug!(%thread_id, "discarding next prompt suggestion while composer is unavailable");
return;
}
match result {
Ok(suggestion) => {
tracing::debug!(
latency_ms = latency_ms,
has_suggestion = suggestion.is_some(),
"next prompt suggestion request finished"
);
self.chat_widget.set_next_prompt_suggestion(suggestion);
}
Err(err) => tracing::debug!(
latency_ms = latency_ms,
error = %err,
"next prompt suggestion request failed"
),
}
}

/// Aborts pending generation without clearing the last visible suggestion.
///
/// Input edits use this path so a user can type over the ghost text, clear the
/// draft, and still get the same already-produced suggestion back from
/// `ChatWidget`'s placeholder refresh. In-flight requests also get a
/// best-effort app-server cancellation so hidden sampling does not continue
/// after the UI has invalidated it.
pub(super) fn cancel_pending_next_prompt_suggestion(&mut self) {
if let Some(pending) = self.pending_next_prompt_suggestion.take() {
pending.task.abort();
if let Some(cancel_request) = pending.cancel_request {
tokio::spawn(async move {
if let Err(err) = super::background_requests::cancel_next_prompt_suggestion(
cancel_request.request_handle,
cancel_request.thread_id,
cancel_request.cancellation_token,
)
.await
{
tracing::debug!(error = %err, "next prompt suggestion cancellation failed");
}
});
}
}
Comment thread
fcoury-oai marked this conversation as resolved.
self.next_prompt_suggestion_generation = self
.next_prompt_suggestion_generation
.saturating_add(/*rhs*/ 1);
}

/// Moves the visible suggestion into editable composer text.
///
/// Acceptance never submits the prompt. Returning `false` means there was no
/// currently stored suggestion to take.
pub(crate) fn accept_next_prompt_suggestion(&mut self) -> bool {
self.cancel_pending_next_prompt_suggestion();
let Some(suggestion) = self.chat_widget.take_next_prompt_suggestion() else {
return false;
};
self.chat_widget
.set_composer_text(suggestion, Vec::new(), Vec::new());
true
Comment thread
fcoury-oai marked this conversation as resolved.
}

/// Returns whether this key event should accept the visible ghost text.
pub(crate) fn next_prompt_suggestion_key_should_accept(&self, key_event: KeyEvent) -> bool {
self.chat_widget.can_show_next_prompt_suggestion()
&& self.chat_widget.has_next_prompt_suggestion()
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
}
)
}
}
3 changes: 3 additions & 0 deletions codex-rs/tui/src/app/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ impl App {
self.thread_event_channels.clear();
self.agent_navigation.clear();
self.side_threads.clear();
self.clear_next_prompt_suggestion();
self.active_thread_id = None;
self.active_thread_rx = None;
self.primary_thread_id = None;
Expand Down Expand Up @@ -545,9 +546,11 @@ impl App {
initial_user_message,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
let thread_id = started.session.thread_id;
self.enqueue_primary_thread_session(started.session, started.turns)
.await?;
self.backfill_loaded_subagent_threads(app_server).await;
self.request_next_prompt_suggestion_for_thread(app_server, thread_id);
Ok(())
}

Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/app/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ pub(super) async fn make_test_app() -> App {
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false,
next_prompt_suggestion_generation: 0,
pending_next_prompt_suggestion: None,
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
}
Expand Down
Loading
Loading