mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Preserve TUI drafts after app-server disconnects (#41911)
## Why Losing an external app-server connection can leave a submission's outcome uncertain. The TUI must preserve local work without retrying it or allowing stale server events to trigger more actions. ## What changed - Enter an offline state when an external app-server transport disconnects during startup, event streaming, or submission. - Keep conversation drafts, queued input, expanded pastes, attachments, and agent-overview input editable while blocking submissions, remote actions, and automatic queue replay. - Show a connection-lost notice that directs users to copy their work and relaunch, while retaining `Ctrl-C` and an empty-composer `Ctrl-D` as quit shortcuts. - Cancel pending views and asynchronous work, and ignore stale completions after the disconnect. Embedded app-server behavior is unchanged. ## Testing Added unit, snapshot, and pseudo-terminal coverage for lost startup and mutation replies, offline draft editing, queued-input recovery, stale-event quarantine, agent-overview behavior, and end-to-end preservation without reconnection or resubmission. GitOrigin-RevId: 7219a95fad676dda447d625db9e465345b469d58
This commit is contained in:
@@ -229,6 +229,7 @@ mod platform_actions;
|
||||
mod plugin_mentions;
|
||||
mod rate_limit_refresh;
|
||||
mod recap;
|
||||
mod reconnect;
|
||||
mod replay_filter;
|
||||
mod resize_reflow;
|
||||
mod safety_buffering;
|
||||
@@ -590,6 +591,7 @@ pub(crate) struct App {
|
||||
feedback_audience: FeedbackAudience,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
app_server_target: AppServerTarget,
|
||||
reconnect: reconnect::ReconnectState,
|
||||
/// Set when the user confirms an update; propagated on exit.
|
||||
pub(crate) pending_update_action: Option<UpdateAction>,
|
||||
|
||||
@@ -787,6 +789,18 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: TuiEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
if self.reconnect.offline
|
||||
&& let TuiEvent::Key(key) = &event
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& let KeyCode::Char(character) = key.code
|
||||
&& (character.eq_ignore_ascii_case(&'c')
|
||||
|| (character.eq_ignore_ascii_case(&'d')
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& self.chat_widget.no_modal_or_popup_active()))
|
||||
{
|
||||
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
||||
}
|
||||
let screen_size = tui.screen_size_for_event(&event)?;
|
||||
if !matches!(
|
||||
&event,
|
||||
@@ -805,6 +819,17 @@ impl App {
|
||||
event
|
||||
};
|
||||
|
||||
if self.reconnect.offline
|
||||
&& let TuiEvent::Key(key) = &event
|
||||
{
|
||||
if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview {
|
||||
self.chat_widget.handle_disconnected_view_key(*key);
|
||||
} else {
|
||||
self.chat_widget.handle_disconnected_key(*key);
|
||||
}
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
match &event {
|
||||
TuiEvent::FocusLost => {
|
||||
let now = Instant::now();
|
||||
@@ -840,6 +865,15 @@ impl App {
|
||||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
if self.reconnect.offline
|
||||
&& self.reconnect.presentation
|
||||
== reconnect::ReconnectPresentation::Conversation
|
||||
{
|
||||
self.chat_widget.handle_disconnected_key(KeyEvent::new(
|
||||
KeyCode::Null,
|
||||
KeyModifiers::NONE,
|
||||
));
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
|
||||
if self.backtrack_render_pending {
|
||||
|
||||
@@ -257,7 +257,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn agents_overview_view(
|
||||
pub(super) fn agents_overview_view(
|
||||
&self,
|
||||
mut threads: Vec<Thread>,
|
||||
selected_thread_id: Option<ThreadId>,
|
||||
|
||||
@@ -85,6 +85,7 @@ pub(super) struct AgentsOverviewRow {
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct AgentsOverviewViewState {
|
||||
pub(super) input: String,
|
||||
pub(super) connection_notice: Option<&'static str>,
|
||||
search: String,
|
||||
searching: bool,
|
||||
pub(super) status_grouping: bool,
|
||||
@@ -461,12 +462,12 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.agents_keymap.toggle_grouping.is_pressed(key) {
|
||||
let mut state = self.state();
|
||||
state.status_grouping = !state.status_grouping;
|
||||
return;
|
||||
}
|
||||
if self.agents_keymap.search.is_pressed(key) {
|
||||
if self.agents_keymap.search.is_pressed(key) || {
|
||||
let state = self.state();
|
||||
state.connection_notice.is_some()
|
||||
&& state.searching
|
||||
&& self.keymap.action_for(key) == Some(ListAction::Cancel)
|
||||
} {
|
||||
let mut state = self.state();
|
||||
if !state.renaming {
|
||||
state.searching = !state.searching;
|
||||
@@ -476,6 +477,21 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state().connection_notice.is_some() {
|
||||
match self.keymap.action_for(key) {
|
||||
Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false),
|
||||
Some(ListAction::MoveDown) => self.move_selection(/*forward*/ true),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.agents_keymap.toggle_grouping.is_pressed(key) {
|
||||
let mut state = self.state();
|
||||
state.status_grouping = !state.status_grouping;
|
||||
return;
|
||||
}
|
||||
if self.agents_keymap.new_task.is_pressed(key) {
|
||||
let mut state = self.state();
|
||||
state.search.clear();
|
||||
@@ -603,8 +619,12 @@ impl Renderable for AgentsOverviewView {
|
||||
}
|
||||
});
|
||||
let attention = format!("{needs_you} need input");
|
||||
Line::from(format!("{attention} {working} working {ready} ready").dim())
|
||||
.render(inset(summary), buf);
|
||||
if let Some(notice) = self.state().connection_notice {
|
||||
Line::from(notice.cyan()).render(inset(summary), buf);
|
||||
} else {
|
||||
Line::from(format!("{attention} {working} working {ready} ready").dim())
|
||||
.render(inset(summary), buf);
|
||||
}
|
||||
Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim())
|
||||
.render(inset(divider), buf);
|
||||
let body = inset(body);
|
||||
@@ -654,6 +674,11 @@ impl Renderable for AgentsOverviewView {
|
||||
let input = &input[visible_start..];
|
||||
Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()])
|
||||
.render(inset(prompt), buf);
|
||||
if state.connection_notice.is_some() {
|
||||
Line::from("ctrl+c quit · actions paused until the list is refreshed".dim())
|
||||
.render(inset(footer), buf);
|
||||
return;
|
||||
}
|
||||
let list_hint = |action| {
|
||||
self.keymap.primary_hint(action).filter(|hint| {
|
||||
!matches!(hint, ShortcutHint::Single(binding)
|
||||
|
||||
@@ -78,6 +78,9 @@ impl App {
|
||||
.await;
|
||||
}
|
||||
AppServerEvent::Disconnected { message } => {
|
||||
if self.begin_reconnect() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!("app-server event stream disconnected: {message}");
|
||||
self.chat_widget.add_error_message(message.clone());
|
||||
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
|
||||
|
||||
@@ -30,6 +30,19 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: AppEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
if self.reconnect.offline
|
||||
&& !matches!(
|
||||
&event,
|
||||
AppEvent::InsertHistoryCell(_)
|
||||
| AppEvent::AppendMessageHistoryEntry { .. }
|
||||
| AppEvent::BeginInitialHistoryReplayBuffer
|
||||
| AppEvent::BeginThreadSwitchHistoryReplayBuffer
|
||||
| AppEvent::EndInitialHistoryReplayBuffer
|
||||
| AppEvent::FatalExitRequest(_)
|
||||
)
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if self.chat_widget.has_misalignment_policy_violation()
|
||||
&& matches!(
|
||||
event,
|
||||
@@ -722,6 +735,10 @@ impl App {
|
||||
}
|
||||
self.chat_widget.prepare_local_op_submission(&op);
|
||||
if let Err(err) = self.submit_active_thread_op(app_server, op).await {
|
||||
if self.recover_transport_error(&err)
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
let unsupported_permissions = err
|
||||
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
|
||||
.is_some();
|
||||
|
||||
@@ -176,7 +176,7 @@ pub(super) struct RecapRequest {
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn clear_recap_request(&mut self, trigger: RecapTrigger) {
|
||||
pub(super) fn clear_recap_request(&mut self, trigger: RecapTrigger) {
|
||||
self.recap.clear_in_flight_request();
|
||||
|
||||
if !matches!(trigger, RecapTrigger::Manual) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Preserve local work after an external connection is lost. Offline input and stale
|
||||
//! async completions are quarantined; recovery requires relaunching the TUI.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(super) enum ReconnectPresentation {
|
||||
#[default]
|
||||
Conversation,
|
||||
Overview,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ReconnectState {
|
||||
pub(super) offline: bool,
|
||||
pub(super) presentation: ReconnectPresentation,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn recover_transport_error(&mut self, error: &color_eyre::Report) -> bool {
|
||||
let disconnected = matches!(
|
||||
error.downcast_ref::<TypedRequestError>(),
|
||||
Some(TypedRequestError::Transport { .. })
|
||||
);
|
||||
disconnected && self.begin_reconnect()
|
||||
}
|
||||
|
||||
pub(super) fn begin_reconnect(&mut self) -> bool {
|
||||
if matches!(self.app_server_target, AppServerTarget::Embedded) {
|
||||
return false;
|
||||
}
|
||||
if !self.reconnect.offline {
|
||||
self.reconnect.offline = true;
|
||||
self.cancel_pending_key_chord();
|
||||
self.overlay = None;
|
||||
self.commit_animation = None;
|
||||
self.clear_recap_request(crate::app_event::RecapTrigger::Manual);
|
||||
self.reconnect.presentation = if self
|
||||
.chat_widget
|
||||
.selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
|
||||
.is_some()
|
||||
{
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.connection_notice = Some("Disconnected — agent list is stale");
|
||||
if let Some(task) = state.refresh_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
self.agents_overview.request_id = None;
|
||||
self.agents_overview.refresh_pending = false;
|
||||
ReconnectPresentation::Overview
|
||||
} else {
|
||||
self.chat_widget
|
||||
.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));
|
||||
ReconnectPresentation::Conversation
|
||||
};
|
||||
self.chat_widget.pause_for_disconnect();
|
||||
self.startup_pending_protected_request = false;
|
||||
self.abort_all_thread_event_listeners();
|
||||
for (_, (_, task)) in self.dynamic_tool_tasks.drain() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -639,7 +639,7 @@ impl App {
|
||||
pub(super) async fn handle_startup_thread_started(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
result: Result<AppServerStartedThread, String>,
|
||||
result: Result<AppServerStartedThread>,
|
||||
) -> Result<()> {
|
||||
if !self.pending_startup_thread_start {
|
||||
if let Ok(started) = result {
|
||||
@@ -718,6 +718,7 @@ impl App {
|
||||
}
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
Err(err) if self.recover_transport_error(&err) => {}
|
||||
Err(err) => {
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"Failed to start a fresh session through the app server: {err}"
|
||||
|
||||
@@ -37,8 +37,7 @@ fn spawn_startup_thread_start(
|
||||
remote_cwd_override,
|
||||
thread_tool_transport,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
.await;
|
||||
app_event_tx.send(AppEvent::StartupThreadStarted { result });
|
||||
});
|
||||
}
|
||||
@@ -514,6 +513,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
feedback_audience,
|
||||
environment_manager,
|
||||
app_server_target,
|
||||
reconnect: Default::default(),
|
||||
pending_update_action: None,
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
@@ -717,11 +717,15 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
let initial_session_header_pending = waiting_for_initial_session_header
|
||||
&& app.primary_session_configured.is_some()
|
||||
&& has_pending_app_events;
|
||||
let block_terminal_input_for_pending_startup_events = initial_session_header_pending
|
||||
|| (pending_startup_draft.is_some() || app.startup_protected_input_boundary)
|
||||
&& has_pending_app_events
|
||||
|| (!waiting_for_initial_session_configured
|
||||
&& app.has_queued_startup_protected_request());
|
||||
let block_terminal_input_for_pending_startup_events =
|
||||
(!matches!(app.app_server_target, AppServerTarget::Embedded)
|
||||
&& has_pending_app_events)
|
||||
|| initial_session_header_pending
|
||||
|| (pending_startup_draft.is_some()
|
||||
|| app.startup_protected_input_boundary)
|
||||
&& has_pending_app_events
|
||||
|| (!waiting_for_initial_session_configured
|
||||
&& app.has_queued_startup_protected_request());
|
||||
let control = select! {
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
let is_initial_session_header = matches!(
|
||||
@@ -744,6 +748,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
AppRunControl::Continue
|
||||
}
|
||||
Ok(AppRunControl::Exit(reason)) => AppRunControl::Exit(reason),
|
||||
Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue,
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
}
|
||||
@@ -756,7 +761,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}, if App::should_handle_active_thread_events(
|
||||
waiting_for_initial_session_configured,
|
||||
app.active_thread_rx.is_some()
|
||||
) && !has_pending_app_events => {
|
||||
) && !has_pending_app_events && !app.reconnect.offline => {
|
||||
if let Some(event) = active {
|
||||
if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await {
|
||||
break Err(err);
|
||||
@@ -766,13 +771,14 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
AppRunControl::Continue
|
||||
}
|
||||
event = tui_events.next(), if !block_terminal_input_for_pending_startup_events => {
|
||||
event = tui_events.next(), if app.reconnect.offline || !block_terminal_input_for_pending_startup_events => {
|
||||
if let Some(event) = event {
|
||||
if (matches!(
|
||||
&event,
|
||||
TuiEvent::Key(key)
|
||||
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
) || matches!(&event, TuiEvent::Paste(_)))
|
||||
&& !app.reconnect.offline
|
||||
&& pending_startup_draft.is_none()
|
||||
&& !waiting_for_initial_session_configured
|
||||
&& app_event_rx.is_empty()
|
||||
@@ -789,18 +795,21 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
match app.handle_tui_event(tui, &mut app_server, event).await {
|
||||
Ok(control) => control,
|
||||
Err(err) => break Err(err),
|
||||
Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue,
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("terminal input stream closed; shutting down active thread");
|
||||
app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await
|
||||
}
|
||||
}
|
||||
app_server_event = app_server.next_event(), if listen_for_app_server_events => {
|
||||
app_server_event = app_server.next_event(), if listen_for_app_server_events && !app.reconnect.offline
|
||||
&& (matches!(app.app_server_target, AppServerTarget::Embedded) || !has_pending_app_events) => {
|
||||
match app_server_event {
|
||||
Some(event) => app.handle_app_server_event(&app_server, event).await,
|
||||
None => {
|
||||
listen_for_app_server_events = false;
|
||||
app.begin_reconnect();
|
||||
tracing::warn!("app-server event stream closed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
app_server_target: crate::AppServerTarget::Embedded,
|
||||
reconnect: Default::default(),
|
||||
pending_update_action: None,
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
|
||||
@@ -12,6 +12,8 @@ mod backend_banner_startup_tests;
|
||||
mod background_exit_tests;
|
||||
#[path = "tests/connector_policy.rs"]
|
||||
mod connector_policy;
|
||||
#[path = "tests/disconnect_tests.rs"]
|
||||
mod disconnect;
|
||||
#[path = "tests/key_chords.rs"]
|
||||
mod key_chords;
|
||||
#[path = "tests/mcp_startup.rs"]
|
||||
@@ -5453,6 +5455,7 @@ async fn make_test_app() -> App {
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
app_server_target: crate::AppServerTarget::Embedded,
|
||||
reconnect: Default::default(),
|
||||
pending_update_action: None,
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
@@ -5535,6 +5538,7 @@ async fn make_test_app_with_channels() -> (
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
app_server_target: crate::AppServerTarget::Embedded,
|
||||
reconnect: Default::default(),
|
||||
pending_update_action: None,
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Offline editing, uncertain submissions, and stale events never consume local work.
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::ThreadParamsMode;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()> {
|
||||
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
|
||||
let id = ThreadId::new();
|
||||
app.active_thread_id = Some(id);
|
||||
app.primary_thread_id = Some(id);
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
|
||||
assert_eq!(request.method, "turn/start");
|
||||
std::future::ready(None)
|
||||
})
|
||||
.await
|
||||
});
|
||||
let mut session = AppServerSession::new(
|
||||
crate::connect_remote_app_server(endpoint).await?,
|
||||
ThreadParamsMode::Remote,
|
||||
);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("only once".into());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let op = next_user_turn_op(&mut ops);
|
||||
app.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("queued before disconnect".into());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("keep this draft".into());
|
||||
let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(overview));
|
||||
app.chat_widget.show_selection_view(SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Stale server action".into(),
|
||||
actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(
|
||||
app.handle_event(&mut tui, &mut session, AppEvent::CodexOp(op))
|
||||
.await?,
|
||||
AppRunControl::Continue
|
||||
));
|
||||
assert!(app.reconnect.offline);
|
||||
assert!(!app.chat_widget.has_active_view());
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('D'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE),
|
||||
] {
|
||||
assert!(matches!(
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
|
||||
.await?,
|
||||
AppRunControl::Continue
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"keep this draft!"
|
||||
);
|
||||
assert!(app.chat_widget.has_queued_follow_up_messages());
|
||||
app.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||||
assert!(!app.chat_widget.maybe_send_next_queued_input());
|
||||
app.handle_event(&mut tui, &mut session, AppEvent::NewSession { name: None })
|
||||
.await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(id));
|
||||
while let Ok(event) = events.try_recv() {
|
||||
app.handle_event(&mut tui, &mut session, event).await?;
|
||||
}
|
||||
let history = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
|
||||
loop {
|
||||
if let Ok(text) =
|
||||
tokio::fs::read_to_string(app.config.codex_home.join("history.jsonl")).await
|
||||
&& let Ok(entry) =
|
||||
serde_json::from_str::<codex_message_history::HistoryEntry>(&text)
|
||||
{
|
||||
break entry;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(history.session_id, history.text),
|
||||
(id.to_string(), "only once".into())
|
||||
);
|
||||
assert_snapshot!(
|
||||
"offline_draft",
|
||||
format!(
|
||||
"{}\n{}",
|
||||
lines_to_single_string(
|
||||
&app.transcript_cells
|
||||
.last()
|
||||
.unwrap()
|
||||
.display_lines(/*width*/ 80)
|
||||
),
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 80)
|
||||
)
|
||||
);
|
||||
let mut keymap = codex_config::types::TuiKeymap::default();
|
||||
keymap.chat.edit_queued_message = Some(codex_config::types::KeybindingsSpec::One(
|
||||
codex_config::types::KeybindingSpec("ctrl-x up".into()),
|
||||
));
|
||||
let runtime =
|
||||
RuntimeKeymap::from_config(&keymap).map_err(|error| color_eyre::eyre::eyre!(error))?;
|
||||
app.chat_widget.apply_keymap_update(keymap, &runtime);
|
||||
app.keymap = runtime;
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
] {
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
|
||||
.await?;
|
||||
}
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"queued before disconnect"
|
||||
);
|
||||
assert!(!app.chat_widget.has_queued_follow_up_messages());
|
||||
let paste = "copied text ".repeat(/*n*/ 92);
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste(paste.clone()))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
format!("queued before disconnect{paste}")
|
||||
);
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||||
let draft = rendered
|
||||
.split_once("› ")
|
||||
.unwrap()
|
||||
.1
|
||||
.split("\n\n")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_snapshot!("offline_expanded_paste", draft);
|
||||
assert!(ops.try_recv().is_err());
|
||||
for character in ['c', 'C', 'd', 'D'] {
|
||||
if character.eq_ignore_ascii_case(&'d') {
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
assert!(matches!(
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(
|
||||
KeyCode::Char(character),
|
||||
KeyModifiers::CONTROL
|
||||
))
|
||||
)
|
||||
.await?,
|
||||
AppRunControl::Exit(ExitReason::UserRequested)
|
||||
));
|
||||
}
|
||||
session.shutdown().await?;
|
||||
assert_eq!(server.await??, vec!["initialize", "turn/start"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<()> {
|
||||
let (mut app, mut events, _) = make_test_app_with_channels().await;
|
||||
let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
assert!(!app.begin_reconnect(), "embedded behavior stays unchanged");
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?,
|
||||
};
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.agents_overview.view_state.lock().unwrap().input = "task draft".into();
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)),
|
||||
)
|
||||
.await?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Paste("search query".into()),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.begin_reconnect());
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
|
||||
] {
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
|
||||
.await?;
|
||||
}
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
|
||||
.await?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().input,
|
||||
"task draft!"
|
||||
);
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. }))
|
||||
);
|
||||
assert_snapshot!(
|
||||
"offline_command_center",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100)
|
||||
);
|
||||
session.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Common framing/bootstrap for both transports. Returning None deliberately loses a reply.
|
||||
pub(super) async fn serve_reconnect_requests<S, F>(
|
||||
mut socket: tokio_tungstenite::WebSocketStream<S>,
|
||||
mut respond: impl FnMut(JSONRPCRequest) -> F,
|
||||
) -> Result<Vec<String>>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
F: std::future::Future<Output = Option<serde_json::Value>>,
|
||||
{
|
||||
let mut methods = Vec::new();
|
||||
while let Some(Ok(frame)) = socket.next().await {
|
||||
let Message::Text(text) = frame else { continue };
|
||||
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
|
||||
continue;
|
||||
};
|
||||
methods.push(request.method.clone());
|
||||
let request_id = request.id.clone();
|
||||
let response = match request.method.as_str() {
|
||||
"initialize" => Some(json!({"result": {"userAgent": "reconnect-test"}})),
|
||||
"account/read" => {
|
||||
Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}}))
|
||||
}
|
||||
"model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
|
||||
"configRequirements/read" => Some(json!({"result": {"requirements": null}})),
|
||||
_ => respond(request).await,
|
||||
};
|
||||
let Some(mut response) = response else {
|
||||
socket.close(None).await?;
|
||||
break;
|
||||
};
|
||||
response["id"] = json!(request_id);
|
||||
socket
|
||||
.send(Message::Text(response.to_string().into()))
|
||||
.await?;
|
||||
}
|
||||
Ok(methods)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lost_initial_thread_reply_keeps_startup_draft_offline() -> Result<()> {
|
||||
for disconnect_first in [false, true] {
|
||||
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
|
||||
app.pending_startup_thread_start = true;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("startup draft".into());
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
|
||||
assert_eq!(request.method, "thread/start");
|
||||
std::future::ready(None)
|
||||
})
|
||||
.await
|
||||
});
|
||||
let mut session = AppServerSession::new(
|
||||
crate::connect_remote_app_server(endpoint).await?,
|
||||
ThreadParamsMode::Remote,
|
||||
);
|
||||
let result = crate::app_server_session::start_thread_with_request_handle(
|
||||
session.request_handle(),
|
||||
app.config.clone(),
|
||||
ThreadParamsMode::Remote,
|
||||
/*remote_cwd_override*/ None,
|
||||
session.thread_tool_transport(),
|
||||
)
|
||||
.await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
if disconnect_first {
|
||||
app.begin_reconnect();
|
||||
}
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::StartupThreadStarted { result },
|
||||
)
|
||||
.await?;
|
||||
assert!(app.reconnect.offline);
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"startup draft"
|
||||
);
|
||||
assert!(
|
||||
!std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. }))
|
||||
);
|
||||
session.shutdown().await?;
|
||||
assert_eq!(server.await??, vec!["initialize", "thread/start"]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -267,6 +267,29 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re
|
||||
"rendered error: {rendered:?}"
|
||||
);
|
||||
|
||||
// A completion quarantined during reconnect must not leave the next recap stuck as busy.
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "ws://127.0.0.1:1".into(),
|
||||
auth_token: None,
|
||||
},
|
||||
};
|
||||
app.request_recap(&app_server, thread_id, RecapTrigger::Manual);
|
||||
let stale = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv())
|
||||
.await?
|
||||
.expect("recap completion before disconnect");
|
||||
assert!(matches!(stale, AppEvent::RecapStarted { .. }));
|
||||
assert!(app.begin_reconnect());
|
||||
app.handle_event(&mut tui, &mut app_server, stale).await?;
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
app.reconnect.offline = false;
|
||||
app.request_recap(&app_server, thread_id, RecapTrigger::Manual);
|
||||
let next = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()).await?;
|
||||
assert!(
|
||||
matches!(next, Some(AppEvent::RecapStarted { .. })),
|
||||
"{next:?}"
|
||||
);
|
||||
|
||||
app_server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: tui/src/app/tests/disconnect_tests.rs
|
||||
expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
---
|
||||
Agent command center
|
||||
Disconnected — agent list is stale
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › task draft!
|
||||
ctrl+c quit · actions paused until the list is refreshed
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/app/tests/disconnect_tests.rs
|
||||
expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.last().unwrap().display_lines(80)),\nrender_bottom_popup(&app.chat_widget, 80))"
|
||||
---
|
||||
■ Connection lost.
|
||||
• Connection lost — copy your draft, then relaunch (0s)
|
||||
|
||||
• Queued follow-up inputs
|
||||
↳ queued before disconnect
|
||||
⌥ + ↑ edit last queued message
|
||||
|
||||
› keep this draft!
|
||||
|
||||
ctrl+c quit
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/app/tests/disconnect_tests.rs
|
||||
expression: draft
|
||||
---
|
||||
queued before disconnectcopied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text copied text copied text
|
||||
copied text copied text copied text copied text
|
||||
@@ -465,6 +465,16 @@ async fn startup_draft_delayed_approval_becomes_protected_on_redraw() -> Result<
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "ws://127.0.0.1:1".into(),
|
||||
auth_token: None,
|
||||
},
|
||||
};
|
||||
app.begin_reconnect();
|
||||
assert!(app.startup_protected_input_boundary);
|
||||
// The replacement connection replays the protected request after the old request was dropped.
|
||||
app.reconnect.offline = false;
|
||||
|
||||
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
|
||||
startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
|
||||
@@ -1232,7 +1242,7 @@ async fn startup_thread_start_failure_returns_error() {
|
||||
.await
|
||||
.expect("embedded app server");
|
||||
let err = app
|
||||
.handle_startup_thread_started(&mut app_server, Err("boom".to_string()))
|
||||
.handle_startup_thread_started(&mut app_server, Err(color_eyre::eyre::eyre!("boom")))
|
||||
.await
|
||||
.expect_err("startup thread failure should exit instead of leaving chat unconfigured");
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Result of the fresh startup thread that is attached after the input UI is live.
|
||||
StartupThreadStarted {
|
||||
result: Result<AppServerStartedThread, String>,
|
||||
result: color_eyre::Result<AppServerStartedThread>,
|
||||
},
|
||||
|
||||
/// Register a dynamically created background thread before its first turn starts.
|
||||
|
||||
@@ -176,7 +176,8 @@ pub(crate) enum ThreadHistorySupport {
|
||||
}
|
||||
|
||||
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
||||
color_eyre::eyre::eyre!("{context}: {err}")
|
||||
let message = format!("{context}: {err}");
|
||||
color_eyre::Report::new(err).wrap_err(message)
|
||||
}
|
||||
|
||||
pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool {
|
||||
|
||||
@@ -107,6 +107,10 @@
|
||||
//! navigation slash commands remain available so users can leave or manage the view. Transcript
|
||||
//! exports also remain available, including an explicit destination filename.
|
||||
//!
|
||||
//! During reconnection, `handle_disconnected_key` edits the draft directly without
|
||||
//! popup dispatch or submission. Enter and Tab leave the draft intact until reconnection succeeds.
|
||||
//! Collapsed pastes expand into editable text so the full draft can be copied before quitting.
|
||||
//!
|
||||
//! # Reasoning Effort Animations
|
||||
//!
|
||||
//! The composer observes the effective reasoning tier whenever model-dependent surfaces refresh.
|
||||
@@ -292,6 +296,7 @@ mod draft_state;
|
||||
mod footer_state;
|
||||
mod history_search;
|
||||
mod popup_state;
|
||||
mod reconnect;
|
||||
mod slash_input;
|
||||
mod vim_search;
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Offline editing bypasses command dispatch and popup actions, retaining the draft in place.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ChatComposer {
|
||||
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
|
||||
self.cancel_history_search();
|
||||
self.attachments.clear_remote_image_selection();
|
||||
self.popups.active = ActivePopup::None;
|
||||
self.set_disable_paste_burst(/*disabled*/ true);
|
||||
// Expand in reverse order so remaining ranges stay valid. Textarea replacement
|
||||
// preserves the cursor and other elements, including images and mentions.
|
||||
let pending_pastes = std::mem::take(&mut self.draft.pending_pastes);
|
||||
if !pending_pastes.is_empty() {
|
||||
for element in self.draft.textarea.text_elements().into_iter().rev() {
|
||||
if let Some((_, text)) = pending_pastes.iter().find(|(placeholder, _)| {
|
||||
element.placeholder(self.draft.textarea.text()) == Some(placeholder.as_str())
|
||||
}) {
|
||||
self.draft
|
||||
.textarea
|
||||
.replace_range(element.byte_range.start..element.byte_range.end, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Enter/Tab and configured submit bindings must never consume the draft offline.
|
||||
// The basic editor reconciles attachments without invoking composer-level shortcuts.
|
||||
if !matches!(key.code, KeyCode::Enter | KeyCode::Tab)
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
{
|
||||
self.handle_input_basic(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "reconnect_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Offline edits retain the same attachment bookkeeping as ordinary text edits.
|
||||
|
||||
use super::super::tests::new_test_composer;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn reconnect_expands_pastes_preserving_images_and_cursor() {
|
||||
let (mut composer, _) = new_test_composer();
|
||||
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
composer.handle_paste(paste.clone());
|
||||
composer.attach_image(PathBuf::from("local.png"));
|
||||
composer.handle_paste(paste);
|
||||
let expanded = composer.current_text_with_pending();
|
||||
let images = composer.draft_snapshot().local_images;
|
||||
composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE));
|
||||
let draft = composer.draft_snapshot();
|
||||
assert_eq!(
|
||||
(
|
||||
draft.text,
|
||||
draft.cursor,
|
||||
draft.local_images,
|
||||
draft.pending_pastes
|
||||
),
|
||||
(
|
||||
format!("{expanded}!"),
|
||||
expanded.len() + 1,
|
||||
images,
|
||||
Vec::new()
|
||||
)
|
||||
);
|
||||
assert_eq!(draft.text_elements.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_edit_removes_deleted_attachment_and_paste_metadata() {
|
||||
let (mut composer, _) = new_test_composer();
|
||||
composer.attach_image(PathBuf::from("local.png"));
|
||||
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
|
||||
composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
let draft = composer.draft_snapshot();
|
||||
assert_eq!(
|
||||
(draft.text, draft.local_images, draft.pending_pastes),
|
||||
(String::new(), Vec::new(), Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_edit_cancels_history_preview_without_losing_original_draft() {
|
||||
let (mut composer, _) = new_test_composer();
|
||||
composer.set_text_content("original draft".into(), Vec::new(), Vec::new());
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 14);
|
||||
composer.begin_history_search();
|
||||
composer.apply_history_search_result(HistorySearchResult::Found(HistoryEntry::new(
|
||||
"history preview".into(),
|
||||
)));
|
||||
composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE));
|
||||
assert_eq!(composer.current_text(), "original draft!");
|
||||
assert!(!composer.history_search_active());
|
||||
}
|
||||
@@ -650,6 +650,16 @@ impl BottomPane {
|
||||
self.push_view(Box::new(modal));
|
||||
}
|
||||
|
||||
/// Edit the draft without invoking popups, submissions, or remote actions.
|
||||
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
|
||||
self.view_stack.clear();
|
||||
self.delayed_approval_requests.clear();
|
||||
self.composer
|
||||
.set_input_enabled(/*enabled*/ true, /*placeholder*/ None);
|
||||
self.composer.handle_disconnected_key(key);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
// If a modal/view is active, handle it here; otherwise forward to composer.
|
||||
@@ -2374,6 +2384,11 @@ mod tests {
|
||||
assert_eq!(pane.composer_text(), "ya");
|
||||
assert!(pane.view_stack.is_empty());
|
||||
assert_eq!(pane.delayed_approval_requests.len(), 1);
|
||||
pane.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));
|
||||
pane.pre_draw_tick_at(Instant::now() + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
|
||||
pane.handle_paste(" kept".into());
|
||||
assert_eq!(pane.composer_text(), "ya kept");
|
||||
assert!(pane.view_stack.is_empty());
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(event, AppEvent::SubmitThreadOp { .. }),
|
||||
|
||||
@@ -354,6 +354,7 @@ mod goal_menu;
|
||||
mod ide_context;
|
||||
use self::ide_context::IdeContextState;
|
||||
mod input_queue;
|
||||
mod reconnect;
|
||||
use self::input_queue::InputQueueState;
|
||||
mod input_flow;
|
||||
mod input_restore;
|
||||
|
||||
@@ -143,6 +143,7 @@ impl ChatWidget {
|
||||
|| self.misalignment_policy_violation
|
||||
|| self.input_queue.suppress_queue_autosend
|
||||
|| self.input_queue.rate_limit_recovery_pending
|
||||
|| self.input_queue.recovered_queue
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(super) struct InputQueueState {
|
||||
pub(super) suppress_queue_autosend: bool,
|
||||
/// Hold submissions while a usage failure or backend-directed model fallback is resolved.
|
||||
pub(super) rate_limit_recovery_pending: bool,
|
||||
pub(super) recovered_queue: bool,
|
||||
}
|
||||
|
||||
impl InputQueueState {
|
||||
@@ -52,6 +53,7 @@ impl InputQueueState {
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.recovered_queue = false;
|
||||
self.queued_user_messages.clear();
|
||||
self.queued_user_message_history_records.clear();
|
||||
self.user_turn_pending_start = false;
|
||||
|
||||
@@ -179,6 +179,8 @@ impl ChatWidget {
|
||||
|
||||
pub(super) fn pop_latest_queued_composer_state(&mut self) -> Option<ThreadComposerState> {
|
||||
if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() {
|
||||
self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages()
|
||||
|| !self.input_queue.pending_steers.is_empty();
|
||||
let history_record = self
|
||||
.input_queue
|
||||
.queued_user_message_history_records
|
||||
@@ -195,6 +197,8 @@ impl ChatWidget {
|
||||
))
|
||||
} else {
|
||||
let user_message = self.input_queue.rejected_steers_queue.pop_back()?;
|
||||
self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages()
|
||||
|| !self.input_queue.pending_steers.is_empty();
|
||||
let history_record = self
|
||||
.input_queue
|
||||
.rejected_steer_history_records
|
||||
@@ -467,6 +471,7 @@ impl ChatWidget {
|
||||
.input_queue
|
||||
.queued_user_message_history_records
|
||||
.clone(),
|
||||
recovered_queue: self.input_queue.recovered_queue,
|
||||
user_turn_pending_start: self.input_queue.user_turn_pending_start,
|
||||
submit_pending_steers_after_interrupt: self
|
||||
.input_queue
|
||||
@@ -487,6 +492,7 @@ impl ChatWidget {
|
||||
let restored_task_running =
|
||||
preserve_in_flight_turn && input_state.as_ref().is_some_and(|state| state.task_running);
|
||||
if let Some(input_state) = input_state {
|
||||
self.input_queue.recovered_queue = input_state.recovered_queue;
|
||||
self.current_collaboration_mode = input_state.current_collaboration_mode;
|
||||
self.active_collaboration_mask = input_state.active_collaboration_mask;
|
||||
self.safety_buffering_prompt = input_state.safety_buffering_prompt;
|
||||
@@ -559,6 +565,8 @@ impl ChatWidget {
|
||||
self.input_queue.clear();
|
||||
self.restore_composer_state(Default::default());
|
||||
}
|
||||
self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages()
|
||||
|| !self.input_queue.pending_steers.is_empty();
|
||||
let effort = self.effective_reasoning_effort();
|
||||
self.bottom_pane
|
||||
.set_active_reasoning_effort_baseline(effort.as_ref());
|
||||
|
||||
@@ -245,6 +245,10 @@ impl ChatWidget {
|
||||
self.bottom_pane.selected_index_for_present_view(view_id)
|
||||
}
|
||||
|
||||
pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option<usize> {
|
||||
self.bottom_pane.selected_index_for_active_view(view_id)
|
||||
}
|
||||
|
||||
pub(crate) fn replace_selection_view_if_present(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Disconnects preserve editable input but never automatically retry queued submissions.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn pause_for_disconnect(&mut self) {
|
||||
self.input_queue.recovered_queue = true;
|
||||
self.input_queue.suppress_queue_autosend = true;
|
||||
self.set_initial_user_message_submit_suppressed(/*suppressed*/ true);
|
||||
if let Some(message) = self.initial_user_message.take() {
|
||||
self.restore_user_message_to_composer(message);
|
||||
}
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane
|
||||
.set_interrupt_hint_visible(/*visible*/ false);
|
||||
self.set_status_header("Connection lost — copy your draft, then relaunch".to_string());
|
||||
self.set_footer_hint_override(Some(vec![("ctrl+c".into(), "quit".into())]));
|
||||
self.add_error_message("Connection lost.".into());
|
||||
}
|
||||
|
||||
pub(crate) fn handle_disconnected_view_key(&mut self, key: KeyEvent) {
|
||||
self.bottom_pane.handle_key_event(key);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
|
||||
if key.kind == KeyEventKind::Press && self.chat_keymap.edit_queued_message.is_pressed(key) {
|
||||
if let Some(composer) = self.pop_latest_queued_composer_state() {
|
||||
self.restore_composer_state(composer);
|
||||
} else if let Some(steer) = self.input_queue.pending_steers.pop_back() {
|
||||
self.restore_user_message_to_composer(user_message_for_restore(
|
||||
steer.user_message,
|
||||
&steer.history_record,
|
||||
));
|
||||
self.input_queue.recovered_queue &= !self.input_queue.pending_steers.is_empty();
|
||||
}
|
||||
self.bottom_pane
|
||||
.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));
|
||||
self.refresh_pending_input_preview();
|
||||
self.request_redraw();
|
||||
} else {
|
||||
self.bottom_pane.handle_disconnected_key(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,17 @@ async fn parent_owned_thread_blocks_settings_shortcuts() {
|
||||
assert_chatwidget_snapshot!("parent_owned_thread_rejects_settings_shortcuts", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_restores_initial_prompt_without_submitting_it() {
|
||||
let (mut chat, _events, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.initial_user_message = Some("CLI prompt".into());
|
||||
chat.restore_user_message_to_composer("typed draft".into());
|
||||
chat.pause_for_disconnect();
|
||||
chat.submit_initial_user_message_if_pending();
|
||||
assert_eq!(chat.composer_text_with_pending(), "CLI prompt\ntyped draft");
|
||||
assert_no_submit_op(&mut ops);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_owned_thread_restores_pending_initial_prompt() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await;
|
||||
@@ -1757,6 +1768,7 @@ async fn restore_thread_input_state_applies_running_state_policy() {
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages: VecDeque::from([UserMessage::from("already queued").into()]),
|
||||
queued_user_message_history_records: VecDeque::from([queued_history.clone()]),
|
||||
recovered_queue: false,
|
||||
user_turn_pending_start: true,
|
||||
submit_pending_steers_after_interrupt: true,
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
@@ -1790,6 +1802,21 @@ async fn restore_thread_input_state_applies_running_state_policy() {
|
||||
Some(UserMessage::from("buffered prompt"))
|
||||
);
|
||||
|
||||
chat.pause_for_disconnect();
|
||||
chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
assert!(!chat.has_queued_follow_up_messages());
|
||||
// Editing the last queued draft must not release the uncertain steer for replay.
|
||||
assert!(chat.capture_thread_input_state().unwrap().recovered_queue);
|
||||
chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
assert_eq!(
|
||||
chat.composer_text_with_pending(),
|
||||
"submitted history\nqueued history"
|
||||
);
|
||||
assert!(chat.input_queue.pending_steers.is_empty());
|
||||
assert!(!chat.capture_thread_input_state().unwrap().recovered_queue);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
chat.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||||
|
||||
chat.restore_thread_input_state(
|
||||
Some(input_state),
|
||||
ThreadInputStateRestoreMode {
|
||||
|
||||
@@ -384,6 +384,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages,
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
recovered_queue: false,
|
||||
user_turn_pending_start: false,
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
|
||||
@@ -131,6 +131,7 @@ pub(crate) struct ThreadInputState {
|
||||
pub(super) rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
|
||||
pub(super) queued_user_message_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pub(crate) recovered_queue: bool,
|
||||
pub(super) user_turn_pending_start: bool,
|
||||
pub(super) submit_pending_steers_after_interrupt: bool,
|
||||
pub(super) current_collaboration_mode: CollaborationMode,
|
||||
|
||||
Reference in New Issue
Block a user