mirror of
https://github.com/openai/codex.git
synced 2026-11-04 16:26:32 +00:00
Keep the composer editable during TUI startup (#38642)
## Why Configuration and app-server initialization can take time before the main TUI is ready, leaving users unable to begin drafting a prompt. ## What changed - Show a provisional composer while startup work runs and carry its text, cursor position, paste state, and attachments into the initialized chat. - Limit the provisional composer to safe editing and cancellation, and quarantine input around session pickers, approvals, and other actionable startup screens. - Preserve configuration validation before terminal checks and recover terminal state cleanly from startup failures and caught panics. ## Testing - Cover startup editing, multiline and large pastes, keymaps, cancellation, session-picker handoff, approval boundaries, and draft restoration. - Verify non-interactive launches report configuration errors before terminal errors. GitOrigin-RevId: fe04a85cfbbcff21ff87fa81fd17474827858575
This commit is contained in:
1 parent
58d2daba45
commit
a0bed4be21
26 files changed
+4348
-1151
No files matched your search
@@ -3,10 +3,116 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::bottom_pane::ComposerDraftSnapshot;
|
||||
|
||||
use super::user_messages::remap_colliding_paste_placeholders;
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
/// Restore the exact draft entered before the fully initialized composer became available.
|
||||
pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) {
|
||||
let existing_draft = self.bottom_pane.composer_draft_snapshot();
|
||||
let existing_cursor = existing_draft.cursor;
|
||||
let existing_message = UserMessage {
|
||||
text: existing_draft.text,
|
||||
text_elements: existing_draft.text_elements,
|
||||
local_images: existing_draft.local_images,
|
||||
remote_image_urls: existing_draft.remote_image_urls,
|
||||
mention_bindings: existing_draft.mention_bindings,
|
||||
};
|
||||
let existing_has_content =
|
||||
!self.bottom_pane.composer_is_empty() || !existing_draft.pending_pastes.is_empty();
|
||||
|
||||
let startup_message = UserMessage {
|
||||
text: draft.text,
|
||||
text_elements: draft.text_elements,
|
||||
local_images: draft.local_images,
|
||||
remote_image_urls: draft.remote_image_urls,
|
||||
mention_bindings: draft.mention_bindings,
|
||||
};
|
||||
let startup_has_content = !startup_message.text.is_empty()
|
||||
|| !startup_message.local_images.is_empty()
|
||||
|| !startup_message.remote_image_urls.is_empty()
|
||||
|| !draft.pending_pastes.is_empty();
|
||||
|
||||
let cursor = if existing_has_content && startup_has_content {
|
||||
let mut used_paste_placeholders = HashSet::new();
|
||||
let (existing_message, mut pending_pastes) = remap_colliding_paste_placeholders(
|
||||
existing_message,
|
||||
existing_draft.pending_pastes,
|
||||
&mut used_paste_placeholders,
|
||||
);
|
||||
let startup_offset = existing_message.text.len().saturating_add(1);
|
||||
let preceding_text_element = startup_message
|
||||
.text_elements
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take_while(|(_, element)| element.byte_range.end <= draft.cursor)
|
||||
.last()
|
||||
.map(|(index, element)| (index, element.byte_range.end));
|
||||
let (startup_message, startup_pending_pastes) = remap_colliding_paste_placeholders(
|
||||
startup_message,
|
||||
draft.pending_pastes,
|
||||
&mut used_paste_placeholders,
|
||||
);
|
||||
let cursor_adjustment = preceding_text_element.map_or(0, |(index, original_end)| {
|
||||
startup_message.text_elements[index]
|
||||
.byte_range
|
||||
.end
|
||||
.saturating_sub(original_end)
|
||||
});
|
||||
pending_pastes.extend(startup_pending_pastes);
|
||||
self.restore_composer_state(Self::composer_state_from_user_message(
|
||||
merge_user_messages(vec![existing_message, startup_message]),
|
||||
pending_pastes,
|
||||
));
|
||||
startup_offset
|
||||
.saturating_add(draft.cursor)
|
||||
.saturating_add(cursor_adjustment)
|
||||
} else if existing_has_content {
|
||||
existing_cursor
|
||||
} else {
|
||||
self.restore_composer_state(Self::composer_state_from_user_message(
|
||||
startup_message,
|
||||
draft.pending_pastes,
|
||||
));
|
||||
draft.cursor
|
||||
};
|
||||
self.bottom_pane.set_composer_cursor(cursor);
|
||||
self.bottom_pane.restore_startup_composer_state(
|
||||
draft.last_composer_activity_at,
|
||||
draft.startup_local_history,
|
||||
);
|
||||
if startup_has_content && self.config.tui_vim_mode_default {
|
||||
self.bottom_pane.enable_vim_in_insert_mode();
|
||||
}
|
||||
}
|
||||
|
||||
/// Includes protected prompts deferred by streaming or the approval idle timer.
|
||||
pub(crate) fn has_pending_protected_request(&self) -> bool {
|
||||
self.bottom_pane.has_pending_approval() || self.interrupts.has_pending_prompt()
|
||||
}
|
||||
|
||||
/// Transfer startup input only after protected views and required sandbox setup finish.
|
||||
pub(crate) fn restore_startup_draft_when_ready(
|
||||
&mut self,
|
||||
pending_draft: &mut Option<ComposerDraftSnapshot>,
|
||||
) {
|
||||
if self.has_active_view()
|
||||
|| self.has_pending_protected_request()
|
||||
|| !self.bottom_pane.composer_input_enabled()
|
||||
{
|
||||
return;
|
||||
}
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
if self.elevated_windows_sandbox_setup_required() {
|
||||
return;
|
||||
}
|
||||
if let Some(draft) = pending_draft.take() {
|
||||
self.restore_startup_draft(draft);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) {
|
||||
self.suppress_initial_user_message_submit = suppressed;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,20 @@ impl InterruptManager {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
|
||||
/// Excludes lifecycle events that never claim protected interactive input.
|
||||
pub(crate) fn has_pending_prompt(&self) -> bool {
|
||||
self.queue.iter().any(|interrupt| {
|
||||
matches!(
|
||||
interrupt,
|
||||
QueuedInterrupt::ExecApproval(_)
|
||||
| QueuedInterrupt::ApplyPatchApproval(_)
|
||||
| QueuedInterrupt::Elicitation { .. }
|
||||
| QueuedInterrupt::RequestPermissions(_)
|
||||
| QueuedInterrupt::RequestUserInput(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecApproval(ev));
|
||||
}
|
||||
@@ -80,6 +94,9 @@ impl InterruptManager {
|
||||
}
|
||||
|
||||
pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool {
|
||||
if !self.has_pending_prompt() {
|
||||
return false;
|
||||
}
|
||||
let original_len = self.queue.len();
|
||||
self.queue
|
||||
.retain(|queued| !queued.matches_resolved_prompt(request));
|
||||
@@ -196,6 +213,7 @@ mod tests {
|
||||
let mut manager = InterruptManager::new();
|
||||
manager.push_user_input(user_input("call-a", "turn"));
|
||||
manager.push_user_input(user_input("call-b", "turn"));
|
||||
assert!(manager.has_pending_prompt());
|
||||
|
||||
assert!(
|
||||
manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput {
|
||||
@@ -204,10 +222,17 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(manager.queue.len(), 1);
|
||||
assert!(manager.has_pending_prompt());
|
||||
let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else {
|
||||
panic!("expected remaining queued user input");
|
||||
};
|
||||
assert_eq!(remaining.item_id, "call-a");
|
||||
assert!(
|
||||
manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput {
|
||||
call_id: "call-a".to_string(),
|
||||
})
|
||||
);
|
||||
assert!(!manager.has_pending_prompt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -234,6 +259,7 @@ mod tests {
|
||||
fn remove_resolved_prompt_keeps_lifecycle_events() {
|
||||
let mut manager = InterruptManager::new();
|
||||
manager.push_item_started(command_execution("call"));
|
||||
assert!(!manager.has_pending_prompt());
|
||||
|
||||
assert!(
|
||||
!manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval {
|
||||
|
||||
@@ -24,7 +24,9 @@ async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before);
|
||||
let mut after = chat.bottom_pane.composer_draft_snapshot();
|
||||
after.last_composer_activity_at = before.last_composer_activity_at;
|
||||
assert_eq!(after, before);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
@@ -56,7 +58,9 @@ async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
|
||||
.set_composer_text(command.to_string(), Vec::new(), Vec::new());
|
||||
let before = chat.bottom_pane.composer_draft_snapshot();
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before);
|
||||
let mut after = chat.bottom_pane.composer_draft_snapshot();
|
||||
after.last_composer_activity_at = before.last_composer_activity_at;
|
||||
assert_eq!(after, before);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
@@ -120,6 +124,22 @@ async fn parent_owned_thread_restores_pending_initial_prompt() {
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_text(), pending_prompt);
|
||||
assert!(chat.initial_user_message.is_none());
|
||||
|
||||
let (mut startup_chat, _startup_rx, _startup_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
startup_chat.bottom_pane.set_composer_text(
|
||||
"typed during startup".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot());
|
||||
chat.restore_startup_draft_when_ready(&mut pending_draft);
|
||||
|
||||
assert!(pending_draft.is_none());
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
format!("{pending_prompt}\ntyped during startup")
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
@@ -833,6 +853,267 @@ async fn blocked_image_restore_with_remote_images_keeps_local_placeholder_mappin
|
||||
assert_eq!(chat.remote_image_urls(), remote_image_urls);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_preserves_cursor_and_large_paste() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let paste = "x".repeat(/*n*/ 1_001);
|
||||
chat.bottom_pane.handle_paste(paste);
|
||||
chat.bottom_pane.insert_str(" tail");
|
||||
chat.bottom_pane.set_composer_cursor(/*cursor*/ 0);
|
||||
let startup_draft = chat.bottom_pane.composer_draft_snapshot();
|
||||
chat.bottom_pane
|
||||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||||
|
||||
chat.restore_startup_draft(startup_draft.clone());
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), startup_draft);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_merges_existing_prompt_images_and_large_pastes() {
|
||||
let (mut startup_chat, _startup_rx, _startup_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let startup_paste = "x".repeat(/*n*/ 1_001);
|
||||
startup_chat.bottom_pane.handle_paste(startup_paste.clone());
|
||||
startup_chat.bottom_pane.insert_str(" startup draft");
|
||||
startup_chat.bottom_pane.set_composer_cursor(/*cursor*/ 0);
|
||||
let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot();
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let image_placeholder = "[Image #1]";
|
||||
let image_path = PathBuf::from("/tmp/initial-prompt.png");
|
||||
chat.bottom_pane.set_composer_text(
|
||||
format!("{image_placeholder} initial prompt "),
|
||||
vec![TextElement::new(
|
||||
(0..image_placeholder.len()).into(),
|
||||
Some(image_placeholder.to_string()),
|
||||
)],
|
||||
vec![image_path.clone()],
|
||||
);
|
||||
let initial_paste = "y".repeat(/*n*/ 1_001);
|
||||
chat.bottom_pane.handle_paste(initial_paste.clone());
|
||||
let existing_draft = chat.bottom_pane.composer_draft_snapshot();
|
||||
let existing_placeholder = existing_draft.pending_pastes[0].0.clone();
|
||||
|
||||
chat.restore_startup_draft(startup_draft);
|
||||
|
||||
let restored = chat.bottom_pane.composer_draft_snapshot();
|
||||
assert_eq!(
|
||||
(
|
||||
restored.text,
|
||||
restored.cursor,
|
||||
restored.pending_pastes,
|
||||
restored.local_images,
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{}\n{existing_placeholder} #2 startup draft",
|
||||
existing_draft.text
|
||||
),
|
||||
existing_draft.text.len() + 1,
|
||||
vec![
|
||||
(existing_placeholder.clone(), initial_paste),
|
||||
(format!("{existing_placeholder} #2"), startup_paste),
|
||||
],
|
||||
vec![LocalImageAttachment {
|
||||
placeholder: image_placeholder.to_string(),
|
||||
path: image_path,
|
||||
}],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_rebases_cursor_around_colliding_large_pastes() {
|
||||
for (cursor_marker, expected_added_bytes) in [("é", 0), (" 中 ", 3), (" tail", 6)] {
|
||||
let (mut startup_chat, _startup_rx, _startup_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
startup_chat.bottom_pane.insert_str("é");
|
||||
startup_chat
|
||||
.bottom_pane
|
||||
.handle_paste("x".repeat(/*n*/ 1_001));
|
||||
startup_chat.bottom_pane.insert_str(" 中 ");
|
||||
startup_chat
|
||||
.bottom_pane
|
||||
.handle_paste("y".repeat(/*n*/ 1_002));
|
||||
startup_chat.bottom_pane.insert_str(" tail");
|
||||
let startup_text = startup_chat.bottom_pane.composer_text();
|
||||
let startup_cursor = startup_text
|
||||
.find(cursor_marker)
|
||||
.expect("cursor marker should be present")
|
||||
+ cursor_marker.len();
|
||||
startup_chat.bottom_pane.set_composer_cursor(startup_cursor);
|
||||
let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot();
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.handle_paste("a".repeat(/*n*/ 1_001));
|
||||
chat.bottom_pane.insert_str(" ");
|
||||
chat.bottom_pane.handle_paste("b".repeat(/*n*/ 1_002));
|
||||
let existing_text = chat.bottom_pane.composer_text();
|
||||
|
||||
chat.restore_startup_draft(startup_draft);
|
||||
|
||||
let first_placeholder = "[Pasted Content 1001 chars] #2";
|
||||
let second_placeholder = "[Pasted Content 1002 chars] #2";
|
||||
let expected_text =
|
||||
format!("{existing_text}\né{first_placeholder} 中 {second_placeholder} tail");
|
||||
let expected_cursor = existing_text.len() + 1 + startup_cursor + expected_added_bytes;
|
||||
assert_eq!(chat.bottom_pane.composer_text(), expected_text);
|
||||
assert_eq!(chat.bottom_pane.composer_cursor(), expected_cursor);
|
||||
|
||||
chat.bottom_pane.insert_str("✓");
|
||||
let mut expected_edited_text = expected_text;
|
||||
expected_edited_text.insert(expected_cursor, '✓');
|
||||
assert_eq!(chat.bottom_pane.composer_text(), expected_edited_text);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_preserves_cleared_history_across_session_configuration() {
|
||||
for configure_before_restore in [false, true] {
|
||||
let (mut startup_chat, _startup_rx, _startup_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
for text in ["first startup draft", "second startup draft"] {
|
||||
startup_chat
|
||||
.bottom_pane
|
||||
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
|
||||
startup_chat.bottom_pane.on_ctrl_c();
|
||||
}
|
||||
let paste = "x".repeat(/*n*/ 1_001);
|
||||
startup_chat.bottom_pane.handle_paste(paste.clone());
|
||||
let placeholder = startup_chat.bottom_pane.composer_text();
|
||||
startup_chat.bottom_pane.on_ctrl_c();
|
||||
let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot());
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
if configure_before_restore {
|
||||
chat.bottom_pane.set_history_metadata(
|
||||
ThreadId::new(),
|
||||
/*log_id*/ 1,
|
||||
/*entry_count*/ 0,
|
||||
);
|
||||
}
|
||||
chat.restore_startup_draft_when_ready(&mut pending_draft);
|
||||
if !configure_before_restore {
|
||||
chat.bottom_pane.set_history_metadata(
|
||||
ThreadId::new(),
|
||||
/*log_id*/ 1,
|
||||
/*entry_count*/ 0,
|
||||
);
|
||||
}
|
||||
|
||||
assert!(pending_draft.is_none());
|
||||
assert!(chat.bottom_pane.composer_is_empty());
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
let restored = chat.bottom_pane.composer_draft_snapshot();
|
||||
assert_eq!(
|
||||
(
|
||||
restored.text,
|
||||
restored.text_elements,
|
||||
restored.pending_pastes
|
||||
),
|
||||
(
|
||||
placeholder.clone(),
|
||||
vec![TextElement::new(
|
||||
(0..placeholder.len()).into(),
|
||||
Some(placeholder.clone()),
|
||||
)],
|
||||
vec![(placeholder, paste)],
|
||||
)
|
||||
);
|
||||
for expected in ["second startup draft", "first startup draft"] {
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(chat.bottom_pane.composer_text(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_keeps_vim_insert_mode_for_nonempty_drafts() {
|
||||
for (text, expected) in [("draft", "draftx"), ("", "")] {
|
||||
let (mut startup_chat, _startup_rx, _startup_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
startup_chat.bottom_pane.insert_str(text);
|
||||
let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot();
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.tui_vim_mode_default = true;
|
||||
chat.bottom_pane.set_vim_enabled(/*enabled*/ true);
|
||||
chat.restore_startup_draft(startup_draft);
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_text(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_syncs_file_search_with_restored_interior_cursor() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let text = "inspect @src before continuing";
|
||||
let cursor = text.find(" before").expect("file token has trailing text");
|
||||
chat.bottom_pane
|
||||
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
|
||||
chat.bottom_pane.set_composer_cursor(cursor);
|
||||
let startup_draft = chat.bottom_pane.composer_draft_snapshot();
|
||||
chat.bottom_pane
|
||||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
chat.restore_startup_draft(startup_draft.clone());
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), startup_draft);
|
||||
assert!(!chat.bottom_pane.no_modal_or_popup_active());
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::StartFileSearch(query) if query == "src"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_file_search_waits_for_protected_view_and_enabled_input() {
|
||||
let (mut startup_chat, _startup_rx, _startup_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
startup_chat
|
||||
.bottom_pane
|
||||
.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new());
|
||||
let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot());
|
||||
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.open_approvals_popup();
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
chat.restore_startup_draft_when_ready(&mut pending_draft);
|
||||
assert!(chat.has_active_view());
|
||||
assert!(pending_draft.is_some());
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(!chat.has_active_view());
|
||||
chat.bottom_pane
|
||||
.set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None);
|
||||
chat.restore_startup_draft_when_ready(&mut pending_draft);
|
||||
assert!(pending_draft.is_some());
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.all(|event| !matches!(event, AppEvent::StartFileSearch(_)))
|
||||
);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
|
||||
chat.restore_startup_draft_when_ready(&mut pending_draft);
|
||||
|
||||
assert!(pending_draft.is_none());
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::StartFileSearch(query) if query == "src"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in new issue
Block a user