mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Keep the composer responsive while opening new conversations (#61383)
## What changed Show an editable draft while starting a new conversation in the TUI, including on another backend. Hand the draft to the conversation when it opens, keeping submission disabled during the transition. If opening fails, show a failure hint and restore the draft to the current composer. Drain queued input and preserve delayed paste newlines before handing off the draft. ## Testing Add regression tests for editing before thread startup completes, retaining drafts and consuming queued Enter keys after failure, and preserving multiline raw pastes across handoff. GitOrigin-RevId: 40540306b4544c485d0dfe76aeda7a4c8b82b19b
This commit is contained in:
committed by
copyberry
parent
4aac2eacc5
commit
c5b6bf1f21
@@ -56,7 +56,7 @@ impl App {
|
||||
Box::new(StartupDraftPump::new(
|
||||
tui,
|
||||
StartupDraftInitialScreen::Composer,
|
||||
StartupDraftSessionAction::NewFromCommandCenter,
|
||||
StartupDraftSessionAction::NewWhileRunning,
|
||||
))
|
||||
});
|
||||
let mut display_config = self.chat_widget.config_ref().clone();
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::resume_preparation::ResumeSource;
|
||||
use super::session_lifecycle::PreparedFreshSession;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use crate::startup_draft::StartupDraftPump;
|
||||
use futures::TryFutureExt;
|
||||
|
||||
pub(super) enum NewBackendSession {
|
||||
@@ -125,6 +126,33 @@ impl App {
|
||||
backends: &mut BackendSessions,
|
||||
owner: crate::backend_id::BackendId,
|
||||
session: NewBackendSession,
|
||||
) -> Result<(), String> {
|
||||
let previous_thread = self.current_displayed_thread_id();
|
||||
let mut fresh_draft = None;
|
||||
let result = Box::pin(self.switch_to_new_backend_session(
|
||||
tui,
|
||||
backends,
|
||||
owner,
|
||||
session,
|
||||
&mut fresh_draft,
|
||||
))
|
||||
.await;
|
||||
if let Some(draft) = fresh_draft {
|
||||
let opened_new_session = self.current_displayed_thread_id() != previous_thread;
|
||||
draft
|
||||
.handoff_after_navigation(tui, &mut self.chat_widget, opened_new_session)
|
||||
.await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn switch_to_new_backend_session(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
backends: &mut BackendSessions,
|
||||
owner: crate::backend_id::BackendId,
|
||||
session: NewBackendSession,
|
||||
fresh_draft: &mut Option<StartupDraftPump>,
|
||||
) -> Result<(), String> {
|
||||
let slot = backends
|
||||
.listening_slot(owner)
|
||||
@@ -166,7 +194,14 @@ impl App {
|
||||
let responsive_cloud_switch = settings.target == AppServerTarget::Cloud
|
||||
&& matches!(session, NewBackendSession::CarryDraft);
|
||||
let (name, mut draft) = match session {
|
||||
NewBackendSession::Fresh { name } => (name, None),
|
||||
NewBackendSession::Fresh { name } => {
|
||||
*fresh_draft = Some(StartupDraftPump::for_new_session(
|
||||
tui,
|
||||
&settings.local_settings,
|
||||
settings.config.cwd.as_path(),
|
||||
));
|
||||
(name, None)
|
||||
}
|
||||
NewBackendSession::CarryDraft => (
|
||||
self.chat_widget.thread_name(),
|
||||
(!responsive_cloud_switch).then(|| self.chat_widget.composer_draft_snapshot()),
|
||||
@@ -186,7 +221,7 @@ impl App {
|
||||
let events = tui.event_stream();
|
||||
wait_for_cloud_switch(self, tui, bootstrap, events, clipboard_after_bootstrap).await
|
||||
} else {
|
||||
bootstrap.await
|
||||
StartupDraftPump::run_during_navigation(fresh_draft.as_mut(), tui, bootstrap).await
|
||||
}?;
|
||||
let destination_cwd = settings.config.cwd.to_path_buf();
|
||||
if let Err(control) = self
|
||||
@@ -202,7 +237,7 @@ impl App {
|
||||
.map(|_| crate::onboarding::TrustCancelAction::CurrentTask),
|
||||
..Default::default()
|
||||
},
|
||||
/*startup_draft*/ None,
|
||||
fresh_draft.as_mut(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -215,15 +250,32 @@ impl App {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (config, local_settings) = self
|
||||
.load_new_session_config_from(
|
||||
let loaded = if fresh_draft.is_some() {
|
||||
StartupDraftPump::run_during_navigation(
|
||||
fresh_draft.as_mut(),
|
||||
tui,
|
||||
Box::pin(self.load_new_session_config_from(
|
||||
destination,
|
||||
&settings,
|
||||
destination_cwd,
|
||||
/*cloud_switch*/ None,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.load_new_session_config_from(
|
||||
destination,
|
||||
&settings,
|
||||
destination_cwd,
|
||||
responsive_cloud_switch.then_some(&mut *tui),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read new session defaults: {error}"))?;
|
||||
};
|
||||
let (config, local_settings) =
|
||||
loaded.map_err(|error| format!("Failed to read new session defaults: {error}"))?;
|
||||
if let Some(draft) = fresh_draft.as_mut() {
|
||||
draft.apply_settings(&local_settings, config.cwd.as_path());
|
||||
}
|
||||
let host_project_cwd = self.backend_preference_context.local_cwd.clone();
|
||||
let start = destination
|
||||
.start_thread_with_session_start_source(
|
||||
@@ -246,7 +298,7 @@ impl App {
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
start.await
|
||||
StartupDraftPump::run_during_navigation(fresh_draft.as_mut(), tui, start).await
|
||||
}?;
|
||||
if responsive_cloud_switch {
|
||||
draft = Some(self.chat_widget.composer_draft_snapshot());
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::rate_limit_refresh::RateLimitReadStatus;
|
||||
use super::rate_limit_refresh::RateLimitRefreshOutcome;
|
||||
use super::resize_reflow::trailing_run_start;
|
||||
use super::session_lifecycle::FreshSessionStart;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use crate::app_event::CapturedSideStream;
|
||||
@@ -264,7 +265,7 @@ impl App {
|
||||
| AppEvent::NewSession { name }
|
||||
| AppEvent::NewSessionInCurrentWorktree { name } => {
|
||||
self.start_fresh_session(
|
||||
tui, app_server, /*session_start_source*/ None,
|
||||
tui, app_server, FreshSessionStart::NewConversation,
|
||||
/*initial_user_message*/ None, name,
|
||||
)
|
||||
.await;
|
||||
@@ -293,7 +294,7 @@ impl App {
|
||||
self.start_fresh_session(
|
||||
tui,
|
||||
app_server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -557,7 +558,7 @@ impl App {
|
||||
self.start_fresh_session(
|
||||
tui,
|
||||
app_server,
|
||||
Some(ThreadStartSource::Clear),
|
||||
FreshSessionStart::PreserveCurrentView(Some(ThreadStartSource::Clear)),
|
||||
/*initial_user_message*/ None,
|
||||
name,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::app_server_session::is_server_shutting_down_error;
|
||||
use crate::app_server_session::source_agent_path;
|
||||
use crate::app_server_session::thread_blocks_direct_input;
|
||||
use crate::chatwidget::ThreadInputStateRestoreMode;
|
||||
use crate::startup_draft::StartupDraftPump;
|
||||
use crate::streaming::commit_tick::CommitTickScope;
|
||||
use codex_app_server_protocol::ThreadPrepareApprovedPlanParams;
|
||||
use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
@@ -37,6 +38,11 @@ pub(super) enum ThreadAttachPresentation {
|
||||
SessionLineageAfterClear,
|
||||
}
|
||||
|
||||
pub(super) enum FreshSessionStart {
|
||||
NewConversation,
|
||||
PreserveCurrentView(Option<ThreadStartSource>),
|
||||
}
|
||||
|
||||
pub(super) struct PreparedFreshSession {
|
||||
pub(super) started: AppServerStartedThread,
|
||||
pub(super) config: Config,
|
||||
@@ -1217,7 +1223,7 @@ impl App {
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
session_start_source: Option<ThreadStartSource>,
|
||||
start: FreshSessionStart,
|
||||
initial_user_message: Option<crate::chatwidget::UserMessage>,
|
||||
new_thread_name: Option<String>,
|
||||
) {
|
||||
@@ -1251,16 +1257,36 @@ impl App {
|
||||
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
||||
// history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it
|
||||
// until the new session is configured and any replayed turns have been rendered.
|
||||
match self
|
||||
.prepare_fresh_session(app_server, session_start_source)
|
||||
.await
|
||||
{
|
||||
let (session_start_source, mut draft) = match start {
|
||||
FreshSessionStart::NewConversation => (
|
||||
None,
|
||||
Some(StartupDraftPump::for_new_session(
|
||||
tui,
|
||||
&self.local_settings,
|
||||
self.chat_widget.config_ref().cwd.as_path(),
|
||||
)),
|
||||
),
|
||||
FreshSessionStart::PreserveCurrentView(source) => (source, None),
|
||||
};
|
||||
let previous_thread = self.current_displayed_thread_id();
|
||||
let prepared = StartupDraftPump::run_during_navigation(
|
||||
draft.as_mut(),
|
||||
tui,
|
||||
Box::pin(self.prepare_fresh_session(app_server, session_start_source)),
|
||||
)
|
||||
.await;
|
||||
match prepared {
|
||||
Ok(prepared) => {
|
||||
let presentation = if draft.is_some() {
|
||||
ThreadAttachPresentation::FreshWithDraft
|
||||
} else {
|
||||
ThreadAttachPresentation::Fresh
|
||||
};
|
||||
self.adopt_fresh_session(
|
||||
tui,
|
||||
app_server,
|
||||
prepared,
|
||||
ThreadAttachPresentation::Fresh,
|
||||
presentation,
|
||||
initial_user_message,
|
||||
new_thread_name,
|
||||
)
|
||||
@@ -1273,7 +1299,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
if let Some(draft) = draft {
|
||||
let opened_new_session = self.current_displayed_thread_id() != previous_thread;
|
||||
draft
|
||||
.handoff_after_navigation(tui, &mut self.chat_widget, opened_new_session)
|
||||
.await;
|
||||
} else {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_fresh_session(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
use crate::app::session_lifecycle::FreshSessionStart;
|
||||
use crate::app::tests::session_lifecycle_requests::HistoryCapabilities;
|
||||
use crate::app::tests::session_lifecycle_requests::recorded_params;
|
||||
use crate::app::tests::session_lifecycle_requests::start_recording_app_server_with_history;
|
||||
@@ -912,7 +913,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::app::backend_sessions::BackendSessions;
|
||||
use crate::app::session_lifecycle::FreshSessionStart;
|
||||
use crate::app_server_session::AppServerSessionEvent;
|
||||
use codex_app_server_protocol::SkillMetadata;
|
||||
use codex_app_server_protocol::SkillScope;
|
||||
@@ -63,7 +64,7 @@ async fn new_session_refreshes_account_for_the_started_provider() -> Result<()>
|
||||
Box::pin(app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
))
|
||||
@@ -183,7 +184,7 @@ async fn new_sessions_preserve_yolo_launch_and_later_permission_choices() -> Res
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -244,7 +245,7 @@ async fn new_session_preserves_vim_line_yank() -> Result<()> {
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -459,7 +460,7 @@ async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -759,7 +760,7 @@ async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<(
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
Some("keep this request".into()),
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -903,6 +904,7 @@ async fn backend_handoff_errors_are_snapshotted() -> Result<()> {
|
||||
let trust_error = next_history_message(&mut events);
|
||||
insta::assert_snapshot!(trust_error, @"■ Unable to check folder trust: config/read failed while checking remote project trust");
|
||||
|
||||
tui.pause_events();
|
||||
// Starting a fresh thread must observe the same destination trust check as resuming one.
|
||||
app.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
@@ -1373,7 +1375,7 @@ async fn replacement_preserves_remote_launch_paths_and_older_servers() -> Result
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::session_lifecycle_requests::HistoryCapabilities;
|
||||
use super::session_lifecycle_requests::start_recording_app_server_with_history;
|
||||
use super::*;
|
||||
use crate::app::session_lifecycle::FreshSessionStart;
|
||||
use codex_context_fragments::side_conversation_boundary;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -52,7 +53,7 @@ sandbox = "unelevated"
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::app::session_lifecycle::FreshSessionStart;
|
||||
use crate::app_event::TranscriptExportDestination;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::test_support::boxed_future;
|
||||
@@ -4833,7 +4834,7 @@ model_reasoning_effort = "low"
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -5197,7 +5198,7 @@ terminal_visualization_instructions = true
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
@@ -5372,9 +5373,14 @@ async fn change_directory_scenario() -> Result<()> {
|
||||
start_recording_app_server(&app.config, no_list, background).await?;
|
||||
let (rec, plain, req) = (recorded_params, crate::key_hint::plain, &requests);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let (source, message, name) = (None, None, Some("Previous project".to_string()));
|
||||
app.start_fresh_session(&mut tui, &mut server, source, message, name)
|
||||
.await;
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
Some("Previous project".to_string()),
|
||||
)
|
||||
.await;
|
||||
let original = app.chat_widget.thread_id().expect("original thread");
|
||||
let rollout = app.chat_widget.rollout_path().expect("original rollout");
|
||||
let json = r#"{"type":"message","role":"assistant","content":[{"type":"output_text","text":"saved history"}]}"#;
|
||||
@@ -5781,7 +5787,7 @@ fn fresh_session_keeps_its_name_and_draft_when_cycling_backends() -> Result<()>
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ Some("Add User".to_string()),
|
||||
)
|
||||
@@ -5919,6 +5925,7 @@ fn fresh_session_keeps_its_name_and_draft_when_cycling_backends() -> Result<()>
|
||||
.contains_key(&destination_thread)
|
||||
);
|
||||
assert!(!app.thread_event_channels.contains_key(&destination_thread));
|
||||
tui.pause_events();
|
||||
// An automatic fresh session may use another connection without changing
|
||||
// the user's last explicit choice.
|
||||
app.start_new_session_on_other_backend(
|
||||
@@ -6087,7 +6094,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> {
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
/*session_start_source*/ None,
|
||||
FreshSessionStart::PreserveCurrentView(None),
|
||||
/*initial_user_message*/ None,
|
||||
/*new_thread_name*/ None,
|
||||
)
|
||||
|
||||
@@ -232,6 +232,7 @@ async fn unavailable_thread_new_and_clear_start_a_writable_session() -> Result<(
|
||||
.set_local_worktree_operations(/*enabled*/ false);
|
||||
let mut session = start_config_write_test_app_server(&app).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.pause_events();
|
||||
while events.try_recv().is_ok() {}
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer(command.into());
|
||||
|
||||
@@ -109,6 +109,14 @@ impl ChatComposer {
|
||||
self.draft.paste_burst.handoff_deadline()
|
||||
}
|
||||
|
||||
pub(in crate::bottom_pane) fn has_paste_window(&self, now: Instant) -> bool {
|
||||
!self.draft.disable_paste_burst
|
||||
&& self
|
||||
.draft
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
}
|
||||
|
||||
/// Display flushed text without losing the window for its trailing paste keys.
|
||||
pub(in crate::bottom_pane) fn flush_handoff_paste(&mut self, now: Instant) -> bool {
|
||||
let deadline = self.paste_handoff_deadline();
|
||||
|
||||
@@ -131,6 +131,10 @@ impl BottomPane {
|
||||
self.composer.paste_handoff_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn has_startup_paste_window(&self, now: Instant) -> bool {
|
||||
self.composer.has_paste_window(now)
|
||||
}
|
||||
|
||||
pub(crate) fn flush_handoff_paste_if_due(&mut self) -> bool {
|
||||
self.composer
|
||||
.flush_handoff_paste(tokio::time::Instant::now().into_std())
|
||||
|
||||
@@ -76,7 +76,8 @@ pub(crate) enum StartupDraftInitialScreen {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum StartupDraftSessionAction {
|
||||
New,
|
||||
NewFromCommandCenter,
|
||||
/// Opened from a live TUI. Submission stays with the eventual conversation.
|
||||
NewWhileRunning,
|
||||
Resume,
|
||||
Fork,
|
||||
}
|
||||
@@ -201,7 +202,7 @@ impl StartupDraftPump {
|
||||
let mut blossom = crate::empty_state_animation::EmptyStateAnimation::default();
|
||||
if matches!(
|
||||
session_action,
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewWhileRunning
|
||||
) {
|
||||
blossom.start_fresh();
|
||||
}
|
||||
@@ -228,6 +229,68 @@ impl StartupDraftPump {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_new_session(tui: &Tui, settings: &LocalSettings, cwd: &Path) -> Self {
|
||||
let mut draft = Self::new(
|
||||
tui,
|
||||
StartupDraftInitialScreen::Composer,
|
||||
StartupDraftSessionAction::NewWhileRunning,
|
||||
);
|
||||
draft.apply_settings(settings, cwd);
|
||||
draft
|
||||
}
|
||||
|
||||
/// A thread start can commit before its response arrives. Keep waiting for the result
|
||||
/// if the terminal stops accepting input or drawing the provisional composer.
|
||||
pub(crate) async fn run_during_navigation<F>(
|
||||
draft: Option<&mut Self>,
|
||||
tui: &mut Tui,
|
||||
operation: F,
|
||||
) -> F::Output
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
if let Some(draft) = draft {
|
||||
tokio::pin!(operation);
|
||||
match draft.run_until(tui, operation.as_mut()).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to update the new conversation composer");
|
||||
operation.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
operation.await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handoff_after_navigation(
|
||||
mut self,
|
||||
tui: &mut Tui,
|
||||
chat_widget: &mut crate::chatwidget::ChatWidget,
|
||||
opened_new_session: bool,
|
||||
) {
|
||||
if !opened_new_session {
|
||||
self.bottom_pane.set_footer_hint_override(Some(vec![(
|
||||
"New conversation did not open".into(),
|
||||
"draft kept".into(),
|
||||
)]));
|
||||
if let Err(error) = self.show(tui) {
|
||||
tracing::warn!(%error, "failed to render the new conversation failure");
|
||||
}
|
||||
}
|
||||
if let Err(error) = self.settle_navigation_input(tui).await {
|
||||
tracing::warn!(%error, "failed to finish input for the new conversation composer");
|
||||
}
|
||||
if opened_new_session {
|
||||
chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.continue_from(&mut self.blossom.borrow_mut());
|
||||
}
|
||||
chat_widget.restore_startup_draft(self.take_draft());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot {
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
self.bottom_pane.composer_draft_snapshot()
|
||||
@@ -379,6 +442,31 @@ impl StartupDraftPump {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep raw paste keys in the temporary composer until its paste window closes.
|
||||
async fn settle_navigation_input(&mut self, tui: &mut Tui) -> io::Result<()> {
|
||||
loop {
|
||||
for _ in 0..STARTUP_EVENT_BATCH_SIZE {
|
||||
let deadline = self
|
||||
.bottom_pane
|
||||
.composer_paste_deadline()
|
||||
.map(Instant::from_std)
|
||||
.max(
|
||||
self.pending_paste_newline
|
||||
.as_ref()
|
||||
.map(|(started_at, _)| *started_at + STARTUP_PASTE_NEWLINE_TIMEOUT),
|
||||
)
|
||||
.unwrap_or_else(Instant::now);
|
||||
let Ok(Some(event)) = tokio::time::timeout_at(deadline, self.events.next()).await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.handle_event(tui, event)?;
|
||||
}
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an ambiguous paste newline before its provisional input owner disappears.
|
||||
pub(crate) async fn flush_pending_paste_newline(&mut self, tui: &mut Tui) -> io::Result<()> {
|
||||
while let Some((started_at, _)) = &self.pending_paste_newline {
|
||||
@@ -430,7 +518,12 @@ impl StartupDraftPump {
|
||||
}
|
||||
|
||||
fn draw(&mut self, tui: &mut Tui, screen_size: Size) -> io::Result<()> {
|
||||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||
let flushed = if self.session_action == StartupDraftSessionAction::NewWhileRunning {
|
||||
self.bottom_pane.flush_handoff_paste_if_due()
|
||||
} else {
|
||||
self.bottom_pane.flush_paste_burst_if_due()
|
||||
};
|
||||
if flushed {
|
||||
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
|
||||
}
|
||||
if self.bottom_pane.is_in_paste_burst() {
|
||||
@@ -458,7 +551,7 @@ impl StartupDraftPump {
|
||||
let Some(key) = crate::keymap::dispatch_event(action) else {
|
||||
return false;
|
||||
};
|
||||
self.session_action != StartupDraftSessionAction::NewFromCommandCenter
|
||||
self.session_action != StartupDraftSessionAction::NewWhileRunning
|
||||
&& self.bottom_pane.is_startup_submit_key(key)
|
||||
|| !self.bottom_pane.is_startup_composer_action(key)
|
||||
&& self.bottom_pane.is_safe_startup_editor_key(key)
|
||||
@@ -497,7 +590,7 @@ fn startup_draft_renderable<'a>(
|
||||
let mut renderable = FlexRenderable::new();
|
||||
renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header));
|
||||
let loading_message = match session_action {
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => None,
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewWhileRunning => None,
|
||||
StartupDraftSessionAction::Resume => Some(" Resuming session…"),
|
||||
StartupDraftSessionAction::Fork => Some(" Forking session…"),
|
||||
};
|
||||
|
||||
@@ -94,25 +94,31 @@ impl StartupDraftPump {
|
||||
};
|
||||
if key.code == KeyCode::Enter
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.bottom_pane.is_in_paste_burst()
|
||||
&& (self.bottom_pane.is_startup_composer_action(key)
|
||||
|| !self.bottom_pane.is_safe_startup_editor_key(key))
|
||||
{
|
||||
let text_len = self.bottom_pane.composer_text().len();
|
||||
let retained_paste = self.session_action
|
||||
== super::StartupDraftSessionAction::NewWhileRunning
|
||||
&& !self.bottom_pane.is_in_paste_burst()
|
||||
&& self
|
||||
.bottom_pane
|
||||
.has_startup_paste_window(Instant::now().into_std());
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
if self
|
||||
.bottom_pane
|
||||
.composer_text()
|
||||
.len()
|
||||
.saturating_sub(text_len)
|
||||
> 1
|
||||
&& (self.bottom_pane.is_startup_composer_action(key)
|
||||
|| !self.bottom_pane.is_safe_startup_editor_key(key))
|
||||
if retained_paste
|
||||
|| self
|
||||
.bottom_pane
|
||||
.composer_text()
|
||||
.len()
|
||||
.saturating_sub(text_len)
|
||||
> 1
|
||||
{
|
||||
self.pending_paste_newline = Some((Instant::now(), "\n".to_string()));
|
||||
}
|
||||
}
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
&& self.session_action != super::StartupDraftSessionAction::NewFromCommandCenter
|
||||
&& self.session_action != super::StartupDraftSessionAction::NewWhileRunning
|
||||
&& self.bottom_pane.is_startup_submit_key(key)
|
||||
{
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
@@ -141,8 +147,7 @@ impl StartupDraftPump {
|
||||
}
|
||||
let previous_text = self.bottom_pane.composer_text();
|
||||
if let Err(error) = handle_startup_draft_key(&mut self.bottom_pane, key)
|
||||
&& !(self.session_action
|
||||
== super::StartupDraftSessionAction::NewFromCommandCenter
|
||||
&& !(self.session_action == super::StartupDraftSessionAction::NewWhileRunning
|
||||
&& StartupCancelled::matches(&error))
|
||||
{
|
||||
if StartupCancelled::matches(&error)
|
||||
@@ -179,7 +184,7 @@ impl StartupDraftPump {
|
||||
|| !matches!(
|
||||
self.session_action,
|
||||
super::StartupDraftSessionAction::New
|
||||
| super::StartupDraftSessionAction::NewFromCommandCenter
|
||||
| super::StartupDraftSessionAction::NewWhileRunning
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
|
||||
@@ -63,7 +63,7 @@ impl Renderable for OwnedStartupLayout<'_> {
|
||||
};
|
||||
paragraph.render(header, buf);
|
||||
let message = match self.pump.session_action {
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => {
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewWhileRunning => {
|
||||
let composer = (!self.pump.submission_pending)
|
||||
.then(|| self.pump.bottom_pane.empty_state_composer())
|
||||
.flatten();
|
||||
|
||||
@@ -47,7 +47,7 @@ fn new_startup_decoration_tracks_draft_and_session_action() {
|
||||
("", StartupDraftSessionAction::New, true),
|
||||
("", StartupDraftSessionAction::Resume, false),
|
||||
("", StartupDraftSessionAction::Fork, false),
|
||||
("", StartupDraftSessionAction::NewFromCommandCenter, false),
|
||||
("", StartupDraftSessionAction::NewWhileRunning, false),
|
||||
] {
|
||||
pump.session_action = action;
|
||||
pump.submission_pending = pending;
|
||||
|
||||
@@ -32,7 +32,7 @@ async fn startup_enter_confirms_one_visible_draft_and_escape_cancels() {
|
||||
"keep this startup draft with more text"
|
||||
);
|
||||
let draft = pump.bottom_pane.composer_draft_snapshot();
|
||||
pump.session_action = StartupDraftSessionAction::NewFromCommandCenter;
|
||||
pump.session_action = StartupDraftSessionAction::NewWhileRunning;
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("command-center startup stays edit-only");
|
||||
assert!(!pump.take_submission_intent());
|
||||
|
||||
@@ -65,6 +65,123 @@ pub(crate) fn startup_test_pump_with_input(text: &str) -> StartupDraftPump {
|
||||
pump
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_session_composer_renders_edits_before_thread_start_finishes() {
|
||||
let (finish_start, started) = tokio::sync::oneshot::channel();
|
||||
let mut pump = startup_test_pump(
|
||||
[
|
||||
TuiEvent::Paste("draft while opening".into()),
|
||||
TuiEvent::Key(KeyCode::Enter.into()),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
pump.session_action = StartupDraftSessionAction::NewWhileRunning;
|
||||
pump.events = Box::pin(
|
||||
pump.events
|
||||
.chain(futures::stream::once(async move {
|
||||
finish_start.send(()).expect("release thread start");
|
||||
TuiEvent::Draw
|
||||
}))
|
||||
.chain(futures::stream::pending()),
|
||||
);
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
|
||||
StartupDraftPump::run_during_navigation(Some(&mut pump), &mut tui, started)
|
||||
.await
|
||||
.expect("finish thread start");
|
||||
|
||||
let frame = tui.terminal.previous_buffer();
|
||||
let draft_line = frame
|
||||
.content()
|
||||
.chunks(usize::from(frame.area.width))
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>()
|
||||
})
|
||||
.find(|row| row.contains("draft while opening"))
|
||||
.expect("the typed draft was visible while thread start was pending");
|
||||
insta::assert_snapshot!(draft_line.trim(), @"› draft while opening");
|
||||
assert_eq!(pump.take_draft().text, "draft while opening");
|
||||
assert!(!pump.take_submission_intent());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_new_session_drains_queued_enter_before_returning_the_draft() {
|
||||
let mut pump = quiet_startup_test_pump();
|
||||
pump.session_action = StartupDraftSessionAction::NewWhileRunning;
|
||||
pump.bottom_pane.insert_str("draft for the new thread");
|
||||
let (enter_polled, mut enter_was_polled) = tokio::sync::oneshot::channel();
|
||||
pump.events = Box::pin(futures::stream::once(async move {
|
||||
enter_polled.send(()).expect("observe the queued Enter");
|
||||
TuiEvent::Key(KeyCode::Enter.into())
|
||||
}));
|
||||
let (mut old_chat, _events, _rx, _ops) =
|
||||
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
|
||||
pump.handoff_after_navigation(&mut tui, &mut old_chat, /*opened_new_session*/ false)
|
||||
.await;
|
||||
|
||||
assert_eq!(enter_was_polled.try_recv(), Ok(()));
|
||||
let frame = tui.terminal.previous_buffer();
|
||||
let failure_hint = frame
|
||||
.content()
|
||||
.chunks(usize::from(frame.area.width))
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>()
|
||||
})
|
||||
.find(|row| row.contains("New conversation did not open"))
|
||||
.expect("the failure frame was painted before the handoff returned");
|
||||
insta::assert_snapshot!(failure_hint.trim(), @"New conversation did not open draft kept");
|
||||
assert_eq!(
|
||||
old_chat.composer_text_with_pending(),
|
||||
"draft for the new thread"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn new_session_handoff_keeps_delayed_raw_paste_newline() {
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
for draw_before_newline in [false, true] {
|
||||
let mut pump = quiet_startup_test_pump();
|
||||
pump.session_action = StartupDraftSessionAction::NewWhileRunning;
|
||||
for character in "first line".chars() {
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char(character).into()))
|
||||
.expect("buffer the first paste line");
|
||||
}
|
||||
pump.events = Box::pin(
|
||||
futures::stream::once(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 70)).await;
|
||||
if draw_before_newline {
|
||||
TuiEvent::Draw
|
||||
} else {
|
||||
TuiEvent::Key(KeyCode::Enter.into())
|
||||
}
|
||||
})
|
||||
.chain(futures::stream::iter(
|
||||
draw_before_newline
|
||||
.then_some(TuiEvent::Key(KeyCode::Enter.into()))
|
||||
.into_iter()
|
||||
.chain(
|
||||
"second line"
|
||||
.chars()
|
||||
.map(|c| TuiEvent::Key(KeyCode::Char(c).into())),
|
||||
),
|
||||
)),
|
||||
);
|
||||
let (mut chat, _events, _rx, _ops) =
|
||||
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
|
||||
|
||||
pump.handoff_after_navigation(&mut tui, &mut chat, /*opened_new_session*/ true)
|
||||
.await;
|
||||
|
||||
assert_eq!(chat.composer_text_with_pending(), "first line\nsecond line");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_draft_renders_full_empty_and_multiline_composer_frames() {
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user