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:
Benjamin Carlsson
2026-11-13 21:52:22 +00:00
committed by copyberry
parent 4aac2eacc5
commit c5b6bf1f21
17 changed files with 379 additions and 54 deletions
+1 -1
View File
@@ -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();
+59 -7
View File
@@ -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());
+4 -3
View File
@@ -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,
)
+40 -7
View File
@@ -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();
+4
View File
@@ -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())
+98 -5
View File
@@ -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…"),
};
+18 -13
View File
@@ -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(());
+1 -1
View File
@@ -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());
+117
View File
@@ -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();