mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Preserve startup drafts and submit them when the session is ready (#46750)
## Why The startup composer accepted edits but ignored submission. Its first frame also preceded configured screen settings, and startup failures could leave typed text unavailable. ## What changed - Honor configured submit bindings during startup, retaining one confirmation until the session and protected input screens are ready. Repeated submit keys do not duplicate the message; edits, `Esc`, disconnects, or destination changes cancel confirmation while preserving the draft. - Apply local screen, status-line, keymap, and paste settings before the first editable frame. Anchor the composer at the bottom in owned-screen mode and retain the loading frame until resumed history is ready to render. - Keep an in-memory recovery copy through submission acknowledgment. On startup failure, restore the terminal before printing unsent text, expanding paste placeholders and escaping terminal control characters. ## Testing Add regression coverage for deferred single submission, configured key chords, cancellation, protected input, paste handling, draft recovery, initial screen settings, and loading-frame handoff, plus a startup layout snapshot. GitOrigin-RevId: 1eb4c63027f7d59af5d42d8545cd5cb5c3594306
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input
|
||||
//! remains isolated from protected interactive requests until the initialized composer owns it.
|
||||
//! Queued resume history replaces the provisional loading frame only when it is ready to render.
|
||||
|
||||
use super::reconnect::ReconnectState;
|
||||
use super::*;
|
||||
@@ -100,6 +101,26 @@ pub(super) fn startup_model(
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Keep the provisional loading frame until queued history reaches the owned transcript.
|
||||
/// Visible startup decisions and the agent overview must still render immediately.
|
||||
pub(super) fn render_startup_frame(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_event_rx: &mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> Result<()> {
|
||||
if tui.is_owned_screen() && !app_event_rx.is_empty() && !self.chat_widget.has_active_view()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.chat_widget.pre_draw_tick();
|
||||
self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
|
||||
if self.chat_widget.has_active_view() && self.startup_protected_input_boundary {
|
||||
tui.discard_pending_input_before_interactive_screen()?;
|
||||
self.startup_pending_protected_request = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recognizes queued requests before they become visible protected screens.
|
||||
pub(super) fn has_queued_startup_protected_request(&self) -> bool {
|
||||
self.startup_protected_input_boundary
|
||||
@@ -172,16 +193,6 @@ impl App {
|
||||
Err(error.into())
|
||||
}
|
||||
|
||||
fn render_startup_frame(app: &mut App, tui: &mut tui::Tui) -> Result<()> {
|
||||
app.chat_widget.pre_draw_tick();
|
||||
app.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
|
||||
if app.chat_widget.has_active_view() && app.startup_protected_input_boundary {
|
||||
tui.discard_pending_input_before_interactive_screen()?;
|
||||
app.startup_pending_protected_request = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Adopt actual launch ownership before constructing session-local preferences.
|
||||
tui.prepare_owned_screen(config.features.enabled(Feature::TranscriptV2))?;
|
||||
let mut local_settings = crate::local_settings::LocalSettings::for_tui(&config, tui);
|
||||
@@ -922,12 +933,15 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
{
|
||||
return shutdown_on_startup_error(app_server, err).await;
|
||||
}
|
||||
// Input for the cancelled resume/fork must not appear in a later selected session.
|
||||
let mut pending_startup_draft =
|
||||
(!startup_session_cancelled).then(|| startup_draft.into_draft());
|
||||
// Keep cancelled resume/fork text editable, but never carry confirmation to another session.
|
||||
let mut pending_startup_submission =
|
||||
startup_draft.take_submission_intent() && !startup_session_cancelled;
|
||||
let mut pending_startup_draft = Some(startup_draft.into_draft());
|
||||
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
|
||||
app.chat_widget
|
||||
.restore_startup_draft_when_ready(&mut pending_startup_draft);
|
||||
app.chat_widget.restore_startup_input_when_ready(
|
||||
&mut pending_startup_draft,
|
||||
&mut pending_startup_submission,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -940,7 +954,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
|
||||
let event_stream_started_at = Instant::now();
|
||||
tui.schedule_screen_size_recheck(Duration::ZERO);
|
||||
if let Err(err) = render_startup_frame(&mut app, tui) {
|
||||
if let Err(err) = app.render_startup_frame(tui, &app_event_rx) {
|
||||
return shutdown_on_startup_error(app_server, err).await;
|
||||
}
|
||||
let tui_events = tui.event_stream();
|
||||
@@ -1094,7 +1108,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
if !had_active_view
|
||||
&& app.chat_widget.has_active_view()
|
||||
&& let Err(err) = render_startup_frame(&mut app, tui)
|
||||
&& let Err(err) = app.render_startup_frame(tui, &app_event_rx)
|
||||
{
|
||||
break Err(err);
|
||||
}
|
||||
@@ -1236,16 +1250,22 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
if !had_active_view
|
||||
&& app.chat_widget.has_active_view()
|
||||
&& let Err(err) = render_startup_frame(&mut app, tui)
|
||||
&& let Err(err) = app.render_startup_frame(tui, &app_event_rx)
|
||||
{
|
||||
break Err(err);
|
||||
}
|
||||
}
|
||||
match control {
|
||||
AppRunControl::Continue => {
|
||||
if app.reconnect.offline {
|
||||
pending_startup_submission = false;
|
||||
app.chat_widget.cancel_startup_submission();
|
||||
}
|
||||
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
|
||||
app.chat_widget
|
||||
.restore_startup_draft_when_ready(&mut pending_startup_draft);
|
||||
app.chat_widget.restore_startup_input_when_ready(
|
||||
&mut pending_startup_draft,
|
||||
&mut pending_startup_submission,
|
||||
);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if terminal_color_probe_pending
|
||||
@@ -1264,6 +1284,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
let clear_pet_result = tui.clear_ambient_pet_image();
|
||||
let clear_result = tui.terminal.clear();
|
||||
// Keep the alternate screen active until the outer guard restores both keyboard stacks.
|
||||
let exit_reason = match exit_reason_result {
|
||||
Ok(exit_reason) => {
|
||||
clear_pet_result?;
|
||||
|
||||
@@ -76,6 +76,8 @@ mod safety_buffering;
|
||||
mod session_lifecycle_requests;
|
||||
mod session_summary;
|
||||
mod startup;
|
||||
#[path = "tests/startup_frame_tests.rs"]
|
||||
mod startup_frame_tests;
|
||||
#[path = "tests/startup_warnings_tests.rs"]
|
||||
mod startup_warnings_tests;
|
||||
#[path = "tests/stream_animation_tests.rs"]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Startup handoff preserves the loading frame until replayed cells can replace it.
|
||||
|
||||
use super::*;
|
||||
use crate::custom_terminal::test_support::last_rendered_buffer;
|
||||
use crate::startup_draft::tests::quiet_startup_test_pump;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn frame_text(tui: &tui::Tui) -> String {
|
||||
last_rendered_buffer(&tui.terminal)
|
||||
.content
|
||||
.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_startup_preserves_loading_until_resume_replay_is_applied() -> Result<()> {
|
||||
for items in [
|
||||
Vec::new(),
|
||||
vec![ThreadItem::AgentMessage {
|
||||
id: "answer".into(),
|
||||
text: "Retained **answer** after resume.".into(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
delivery: None,
|
||||
questions: None,
|
||||
}],
|
||||
] {
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
app.local_settings.tui.show_tooltips = false;
|
||||
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let thread_id = ThreadId::new();
|
||||
let mut startup = quiet_startup_test_pump();
|
||||
startup.apply_config(&app.config);
|
||||
startup.update_session_selection(
|
||||
&mut tui,
|
||||
&SessionSelection::Resume(crate::resume_picker::SessionTarget {
|
||||
path: None,
|
||||
thread_id,
|
||||
cwd: None,
|
||||
history_mode: None,
|
||||
}),
|
||||
)?;
|
||||
let loading = last_rendered_buffer(&tui.terminal).clone();
|
||||
let loading_cursor = tui.terminal.last_known_cursor_pos;
|
||||
assert!(frame_text(&tui).contains("Resuming session…"));
|
||||
|
||||
let has_answer = !items.is_empty();
|
||||
let turns = if has_answer {
|
||||
vec![test_turn("resumed-turn", TurnStatus::Completed, items)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(thread_id, app.config.cwd.to_path_buf()),
|
||||
turns,
|
||||
)
|
||||
.await?;
|
||||
let mut draft = Some(startup.into_draft());
|
||||
assert!(!events.is_empty());
|
||||
assert!(app.transcript_cells.is_empty());
|
||||
while !events.is_empty() {
|
||||
app.render_startup_frame(&mut tui, &events)?;
|
||||
assert_eq!(last_rendered_buffer(&tui.terminal), &loading);
|
||||
assert_eq!(tui.terminal.last_known_cursor_pos, loading_cursor);
|
||||
let event = events.try_recv()?;
|
||||
let control = Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
}
|
||||
app.chat_widget.restore_startup_draft_when_ready(&mut draft);
|
||||
app.render_startup_frame(&mut tui, &events)?;
|
||||
assert!(draft.is_none());
|
||||
let rendered = frame_text(&tui);
|
||||
assert!(!rendered.contains("Resuming session…"));
|
||||
if has_answer {
|
||||
assert!(rendered.contains("Retained answer after resume."));
|
||||
} else {
|
||||
assert!(rendered.contains("OpenAI Codex"));
|
||||
}
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
app_server.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_startup_renders_visible_decisions_and_overview_before_pending_events() -> Result<()>
|
||||
{
|
||||
for overview in [false, true] {
|
||||
let (mut app, events, _ops) = make_test_app_with_channels().await;
|
||||
app.app_event_tx
|
||||
.send(AppEvent::BeginInitialHistoryReplayBuffer);
|
||||
if overview {
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
} else {
|
||||
let preset = codex_utils_approval_presets::builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
.expect("auto preset");
|
||||
app.chat_widget
|
||||
.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None);
|
||||
}
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
assert!(!events.is_empty());
|
||||
app.render_startup_frame(&mut tui, &events)?;
|
||||
assert!(frame_text(&tui).contains(if overview {
|
||||
"Agent command center"
|
||||
} else {
|
||||
"sandbox"
|
||||
}));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_startup_still_renders_with_pending_history() -> Result<()> {
|
||||
let (mut app, events, _ops) = make_test_app_with_channels().await;
|
||||
app.app_event_tx
|
||||
.send(AppEvent::BeginInitialHistoryReplayBuffer);
|
||||
app.chat_widget
|
||||
.apply_external_edit("inline startup draft".into());
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.render_startup_frame(&mut tui, &events)?;
|
||||
assert!(frame_text(&tui).contains("inline startup draft"));
|
||||
Ok(())
|
||||
}
|
||||
@@ -762,7 +762,14 @@ impl App {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(true),
|
||||
Ok(_) => {
|
||||
if self.active_thread_id == Some(thread_id)
|
||||
&& self.chat_widget.thread_id() == Some(thread_id)
|
||||
{
|
||||
crate::startup_recovery::acknowledged(client_user_message_id);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(turn_error) =
|
||||
active_turn_not_steerable_turn_error(&error)
|
||||
@@ -882,6 +889,7 @@ impl App {
|
||||
if self.active_thread_id == Some(thread_id)
|
||||
&& self.chat_widget.thread_id() == Some(thread_id)
|
||||
{
|
||||
crate::startup_recovery::acknowledged(client_user_message_id);
|
||||
self.chat_widget
|
||||
.record_safety_buffering_turn(response.turn.id, op);
|
||||
}
|
||||
|
||||
@@ -89,8 +89,10 @@
|
||||
//!
|
||||
//! # Startup Draft Handoff
|
||||
//!
|
||||
//! Startup uses a provisional plain-text composer: editing remains available, but submission,
|
||||
//! popups, attachments, and other actions are disabled. [`ComposerDraftSnapshot`] transfers its
|
||||
//! Startup uses a provisional plain-text composer: editing remains available while its owner
|
||||
//! records one submit intent. Popups, attachments, and direct dispatch stay disabled. After the
|
||||
//! protected startup gates finish, `prepare_startup_submission` applies normal submission parsing
|
||||
//! once, including expansion of paste placeholders. [`ComposerDraftSnapshot`] transfers its
|
||||
//! text, cursor, pending paste placeholders, local history, and recent activity to the fully
|
||||
//! initialized composer.
|
||||
//! `ChatWidget` merges the draft with any existing initial prompt and attachments, rebasing cursor
|
||||
@@ -3123,6 +3125,15 @@ impl ChatComposer {
|
||||
result
|
||||
}
|
||||
|
||||
/// Prepare a startup-confirmed draft through the normal queue parser, independent of keymaps.
|
||||
pub(super) fn prepare_startup_submission(&mut self) -> InputResult {
|
||||
// Startup's plain-text editor cannot show shell mode; require confirmation here.
|
||||
if self.is_bang_shell_command() {
|
||||
return InputResult::None;
|
||||
}
|
||||
self.handle_submission(/*should_queue*/ true).0
|
||||
}
|
||||
|
||||
fn reset_vim_mode_after_successful_dispatch(&mut self, result: &InputResult) {
|
||||
if matches!(
|
||||
result,
|
||||
|
||||
@@ -4,6 +4,19 @@ use super::*;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
|
||||
impl ChatComposer {
|
||||
/// Include accepted but unflushed keys without changing live paste detection.
|
||||
pub(crate) fn recovery_snapshot(&self) -> ComposerDraftSnapshot {
|
||||
let mut snapshot = self.draft_snapshot();
|
||||
if let Some(pending) = self.draft.paste_burst.clone().flush_before_modified_input() {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.set_text_with_elements(&snapshot.text, &snapshot.text_elements);
|
||||
textarea.insert_str_at(snapshot.cursor, &pending);
|
||||
snapshot.text = textarea.text().to_owned();
|
||||
snapshot.text_elements = textarea.text_elements();
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Classify an explicit paste before integrating text shared with the buffered key path.
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
self.note_sparkle_paste(&pasted);
|
||||
|
||||
@@ -169,7 +169,7 @@ const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
|
||||
#[cfg(windows)]
|
||||
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PasteBurst {
|
||||
last_plain_char_time: Option<Instant>,
|
||||
consecutive_plain_char_burst: u16,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Bottom-pane adapters for the provisional startup composer and its protected handoff.
|
||||
//!
|
||||
//! Startup accepts safe editor input while keeping submission, search, and other actions
|
||||
//! unavailable. Once initialization finishes, these adapters preserve buffered paste text,
|
||||
//! Startup accepts safe editor input and records submission intent while keeping search and
|
||||
//! other actions unavailable. Once initialization finishes, these adapters preserve buffered paste text,
|
||||
//! composer history and cursor state, and the approval-idle timing inherited by the real composer.
|
||||
|
||||
use std::time::Instant;
|
||||
@@ -14,6 +14,16 @@ use super::HistoryEntry;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
|
||||
impl BottomPane {
|
||||
/// Use the effective submit binding for startup confirmation as soon as config is loaded.
|
||||
pub(crate) fn is_startup_submit_key(&self, key_event: KeyEvent) -> bool {
|
||||
self.keymap.composer.submit.is_pressed(key_event)
|
||||
}
|
||||
|
||||
/// Consume a confirmed startup draft only after its caller has checked submission readiness.
|
||||
pub(crate) fn prepare_startup_submission(&mut self) -> super::InputResult {
|
||||
self.composer.prepare_startup_submission()
|
||||
}
|
||||
|
||||
/// Return whether a key would submit, queue, or search outside the startup draft.
|
||||
pub(crate) fn is_startup_composer_action(&self, key_event: KeyEvent) -> bool {
|
||||
let composer = &self.keymap.composer;
|
||||
@@ -30,9 +40,27 @@ impl BottomPane {
|
||||
|
||||
/// Return whether a key is bound only to local startup-safe text editing.
|
||||
pub(crate) fn is_safe_startup_editor_key(&self, key_event: KeyEvent) -> bool {
|
||||
let editor = &self.keymap.editor;
|
||||
self.is_startup_cursor_key(key_event)
|
||||
|| [
|
||||
&editor.insert_newline,
|
||||
&editor.delete_backward,
|
||||
&editor.delete_forward,
|
||||
&editor.delete_backward_word,
|
||||
&editor.delete_forward_word,
|
||||
&editor.kill_line_start,
|
||||
&editor.kill_whole_line,
|
||||
&editor.kill_line_end,
|
||||
&editor.yank,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|bindings| bindings.is_pressed(key_event))
|
||||
}
|
||||
|
||||
/// Resolve cursor movement through the same configured bindings as the editor.
|
||||
pub(crate) fn is_startup_cursor_key(&self, key_event: KeyEvent) -> bool {
|
||||
let editor = &self.keymap.editor;
|
||||
[
|
||||
&editor.insert_newline,
|
||||
&editor.move_left,
|
||||
&editor.move_right,
|
||||
&editor.move_up,
|
||||
@@ -41,14 +69,6 @@ impl BottomPane {
|
||||
&editor.move_word_right,
|
||||
&editor.move_line_start,
|
||||
&editor.move_line_end,
|
||||
&editor.delete_backward,
|
||||
&editor.delete_forward,
|
||||
&editor.delete_backward_word,
|
||||
&editor.delete_forward_word,
|
||||
&editor.kill_line_start,
|
||||
&editor.kill_whole_line,
|
||||
&editor.kill_line_end,
|
||||
&editor.yank,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|bindings| bindings.is_pressed(key_event))
|
||||
@@ -73,6 +93,11 @@ impl BottomPane {
|
||||
draft
|
||||
}
|
||||
|
||||
/// Preserve keys still buffered by paste detection if startup exits before its next frame.
|
||||
pub(crate) fn composer_recovery_snapshot(&self) -> ComposerDraftSnapshot {
|
||||
self.composer.recovery_snapshot()
|
||||
}
|
||||
|
||||
/// Restore the visible cursor before synchronizing composer-owned popups.
|
||||
pub(crate) fn set_composer_cursor(&mut self, cursor: usize) {
|
||||
self.composer.set_current_cursor(cursor);
|
||||
|
||||
@@ -348,6 +348,7 @@ mod input_restore;
|
||||
mod input_submission;
|
||||
mod interrupts;
|
||||
mod questions;
|
||||
mod startup_submission;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod keymap_picker;
|
||||
mod mcp_startup;
|
||||
|
||||
@@ -21,6 +21,8 @@ pub(super) struct PendingInputPreview {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct InputQueueState {
|
||||
/// The visible draft confirmed during startup, awaiting the protected-input handoff.
|
||||
pub(super) startup_submission: Option<crate::bottom_pane::ComposerDraftSnapshot>,
|
||||
/// User inputs queued while a turn is in progress.
|
||||
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
|
||||
/// History records for queued user messages. Slash commands such as `/goal`
|
||||
@@ -56,6 +58,7 @@ impl InputQueueState {
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.startup_submission = None;
|
||||
self.recovered_queue = false;
|
||||
self.queued_user_messages.clear();
|
||||
self.queued_user_message_history_records.clear();
|
||||
|
||||
@@ -96,6 +96,7 @@ impl ChatWidget {
|
||||
if startup_has_content && self.local_settings.tui.vim_mode_default {
|
||||
self.bottom_pane.enable_vim_in_insert_mode();
|
||||
}
|
||||
crate::startup_recovery::handed_off(|| self.bottom_pane.composer_recovery_snapshot());
|
||||
}
|
||||
|
||||
/// Includes protected prompts deferred by streaming or the approval idle timer.
|
||||
|
||||
@@ -423,6 +423,7 @@ impl ChatWidget {
|
||||
let submitted_image_display = (render_in_history && !local_images.is_empty())
|
||||
.then(|| Self::user_message_display_from_inputs(&items));
|
||||
let client_user_message_id = uuid::Uuid::new_v4().to_string();
|
||||
crate::startup_recovery::bind_submission(&text, &client_user_message_id);
|
||||
let pending_steer = (!render_in_history).then(|| PendingSteer {
|
||||
client_id: client_user_message_id.clone(),
|
||||
user_message: UserMessage {
|
||||
|
||||
@@ -14,6 +14,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.handle_startup_submission_key(key_event) {
|
||||
return;
|
||||
}
|
||||
if self.handle_question_key(key_event) {
|
||||
return;
|
||||
}
|
||||
@@ -160,6 +163,7 @@ impl ChatWidget {
|
||||
{
|
||||
if let Some(composer) = self.pop_latest_queued_composer_state() {
|
||||
self.restore_composer_state(composer);
|
||||
self.refresh_startup_recovery();
|
||||
self.refresh_pending_input_preview();
|
||||
self.request_redraw();
|
||||
} else {
|
||||
@@ -221,6 +225,13 @@ impl ChatWidget {
|
||||
let should_pause_active_goal =
|
||||
self.bottom_pane.should_interrupt_running_task(key_event);
|
||||
let input_result = self.bottom_pane.handle_key_event(key_event);
|
||||
if matches!(
|
||||
input_result,
|
||||
InputResult::None | InputResult::ParentOwnedInputBlocked
|
||||
) {
|
||||
self.refresh_startup_recovery();
|
||||
}
|
||||
crate::startup_recovery::submitted(&input_result);
|
||||
self.sync_backend_banner_view();
|
||||
if should_pause_active_goal {
|
||||
self.pause_active_goal_for_interrupt();
|
||||
@@ -244,6 +255,7 @@ impl ChatWidget {
|
||||
}
|
||||
tracing::info!("attach_image path={path:?}");
|
||||
self.bottom_pane.attach_image(path);
|
||||
self.refresh_startup_recovery();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -253,6 +265,7 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
||||
self.bottom_pane.apply_external_edit(text);
|
||||
self.refresh_startup_recovery();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -498,12 +511,17 @@ impl ChatWidget {
|
||||
if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
||||
return;
|
||||
}
|
||||
if !self.startup_submission_has_protected_input() {
|
||||
self.cancel_startup_submission();
|
||||
}
|
||||
self.bottom_pane.handle_paste(text);
|
||||
self.refresh_startup_recovery();
|
||||
}
|
||||
|
||||
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||||
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||
self.refresh_startup_recovery();
|
||||
// A paste just flushed; request an immediate redraw and skip this frame.
|
||||
self.request_redraw();
|
||||
true
|
||||
@@ -537,6 +555,7 @@ impl ChatWidget {
|
||||
KeyModifiers::CONTROL,
|
||||
));
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||||
self.refresh_startup_recovery();
|
||||
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
||||
if modal_or_popup_active {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::bottom_pane::RestrictedInputMode;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn pause_for_disconnect(&mut self) {
|
||||
self.cancel_startup_submission();
|
||||
self.cancel_image_submission();
|
||||
// The app-server transport can fail while the separate WebRTC helper
|
||||
// still sends microphone audio. Retire local media before showing offline UI.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Carry one startup submission intent across draft handoff without consuming editable text early.
|
||||
//!
|
||||
//! Confirmation applies to the visible draft only. Editing, cancellation, or disconnect removes
|
||||
//! the intent; normal submission parsing runs once the session and protected-input gates are ready.
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::ComposerDraftSnapshot;
|
||||
|
||||
impl ChatWidget {
|
||||
/// Keep recovery current while startup still owns an unsent, handed-off draft.
|
||||
pub(super) fn refresh_startup_recovery(&self) {
|
||||
crate::startup_recovery::refresh_after_handoff(|| {
|
||||
self.bottom_pane.composer_recovery_snapshot()
|
||||
});
|
||||
}
|
||||
|
||||
/// Restore startup text first, then submit its confirmed contents once the session is ready.
|
||||
pub(crate) fn restore_startup_input_when_ready(
|
||||
&mut self,
|
||||
pending_draft: &mut Option<ComposerDraftSnapshot>,
|
||||
pending_submission: &mut bool,
|
||||
) {
|
||||
let had_draft = pending_draft.is_some();
|
||||
let destination_has_content = !self.bottom_pane.composer_is_empty()
|
||||
|| !self.bottom_pane.composer_pending_pastes().is_empty();
|
||||
self.restore_startup_draft_when_ready(pending_draft);
|
||||
if self.blocks_direct_input {
|
||||
*pending_submission = false;
|
||||
self.cancel_startup_submission();
|
||||
return;
|
||||
}
|
||||
if had_draft
|
||||
&& pending_draft.is_none()
|
||||
&& std::mem::take(pending_submission)
|
||||
&& !destination_has_content
|
||||
{
|
||||
self.input_queue.startup_submission = Some(self.bottom_pane.composer_draft_snapshot());
|
||||
self.bottom_pane.set_footer_hint_override(Some(vec![
|
||||
("Waiting for startup".into(), String::new()),
|
||||
("esc".into(), "cancel".into()),
|
||||
]));
|
||||
}
|
||||
if pending_draft.is_some()
|
||||
|| !self.is_session_configured()
|
||||
|| self
|
||||
.effective_collaboration_mode()
|
||||
.model()
|
||||
.trim()
|
||||
.is_empty()
|
||||
|| !self.bottom_pane.composer_input_enabled()
|
||||
|| self.startup_submission_has_protected_input()
|
||||
|| self.is_plan_streaming_in_tui()
|
||||
|| self.is_user_turn_pending_or_running()
|
||||
|| self.input_queue.suppress_queue_autosend
|
||||
|| self.input_queue.rate_limit_recovery_pending
|
||||
|| self.input_queue.recovered_queue
|
||||
{
|
||||
return;
|
||||
}
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Local
|
||||
&& self.elevated_windows_sandbox_setup_required()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(confirmed) = self.input_queue.startup_submission.take() else {
|
||||
crate::startup_recovery::clear();
|
||||
return;
|
||||
};
|
||||
self.bottom_pane.set_footer_hint_override(/*items*/ None);
|
||||
let current = self.bottom_pane.composer_draft_snapshot();
|
||||
if current.text != confirmed.text
|
||||
|| current.text_elements != confirmed.text_elements
|
||||
|| current.local_images != confirmed.local_images
|
||||
|| current.remote_image_urls != confirmed.remote_image_urls
|
||||
|| current.mention_bindings != confirmed.mention_bindings
|
||||
|| current.pending_pastes != confirmed.pending_pastes
|
||||
{
|
||||
return;
|
||||
}
|
||||
let result = self.bottom_pane.prepare_startup_submission();
|
||||
crate::startup_recovery::submitted(&result);
|
||||
self.handle_composer_input_result(result, /*had_modal_or_popup*/ false);
|
||||
}
|
||||
|
||||
/// Cancel only the intent; the editor keeps the complete draft for editing or recovery.
|
||||
pub(crate) fn cancel_startup_submission(&mut self) {
|
||||
if self.input_queue.startup_submission.take().is_some() {
|
||||
self.bottom_pane.set_footer_hint_override(/*items*/ None);
|
||||
self.refresh_startup_recovery();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_startup_submission_key(&mut self, key: KeyEvent) -> bool {
|
||||
if self.input_queue.startup_submission.is_none()
|
||||
|| self.startup_submission_has_protected_input()
|
||||
|| !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.bottom_pane.is_startup_submit_key(key) {
|
||||
return true;
|
||||
}
|
||||
if key.code == KeyCode::Esc {
|
||||
self.cancel_startup_submission();
|
||||
return true;
|
||||
}
|
||||
if !self.bottom_pane.is_startup_cursor_key(key) {
|
||||
self.cancel_startup_submission();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn startup_submission_has_protected_input(&self) -> bool {
|
||||
self.has_active_view()
|
||||
|| self
|
||||
.bottom_pane
|
||||
.questions
|
||||
.as_ref()
|
||||
.is_some_and(|q| q.expanded)
|
||||
|| self.has_pending_protected_request()
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,8 @@ mod side;
|
||||
mod slash_commands;
|
||||
#[path = "tests/sparkle_submission_tests.rs"]
|
||||
mod sparkle_submission_tests;
|
||||
#[path = "tests/startup_submission_tests.rs"]
|
||||
mod startup_submission_tests;
|
||||
mod status_and_layout;
|
||||
mod status_command_tests;
|
||||
mod status_surface_previews;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_waits_for_session_and_sends_exactly_once() {
|
||||
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.bottom_pane.insert_str("confirmed before startup");
|
||||
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
|
||||
let mut confirmed = true;
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert!(draft.is_none());
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "confirmed before startup");
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
}
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
|
||||
] {
|
||||
chat.handle_key_event(key);
|
||||
}
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let model = chat.effective_collaboration_mode().model().to_string();
|
||||
chat.set_model("");
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "confirmed before startup");
|
||||
chat.set_model(&model);
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text {
|
||||
text: "confirmed before startup".into(), text_elements: Vec::new(),
|
||||
}]);
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_escape_restores_editing_without_submitting() {
|
||||
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.bottom_pane.insert_str("keep the draft");
|
||||
source.bottom_pane.set_composer_cursor(/*cursor*/ 4);
|
||||
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
|
||||
let mut confirmed = true;
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
let before = chat.bottom_pane.composer_draft_snapshot();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_waits_for_protected_view_and_preserves_paste_provenance() {
|
||||
for payload in [
|
||||
" !echo hi\n# visible tail".to_string(),
|
||||
format!("!echo {}", "x".repeat(/*n*/ 1000)),
|
||||
] {
|
||||
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.handle_paste(payload.clone());
|
||||
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
|
||||
let mut confirmed = true;
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.open_approvals_popup();
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert!(draft.is_some());
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
if payload.starts_with(' ') {
|
||||
assert!(op_rx.try_recv().is_err());
|
||||
assert_eq!(chat.bottom_pane.composer_text(), payload);
|
||||
chat.handle_key_event(KeyCode::Enter.into());
|
||||
assert_matches!(op_rx.try_recv(), Ok(Op::RunUserShellCommand { command })
|
||||
if command == payload.trim_start().trim_start_matches('!'));
|
||||
} else {
|
||||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text {
|
||||
text: payload, text_elements: Vec::new(),
|
||||
}]);
|
||||
}
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_edit_or_disconnect_cancels_without_losing_draft() {
|
||||
for action in ["edit", "disconnect", "parent-owned"] {
|
||||
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.bottom_pane.insert_str("confirmed draft");
|
||||
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
|
||||
let mut confirmed = true;
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
if action == "parent-owned" {
|
||||
chat.set_parent_owned_thread();
|
||||
}
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
if action == "disconnect" {
|
||||
chat.pause_for_disconnect();
|
||||
} else if action == "edit" {
|
||||
chat.handle_paste(" edited".into());
|
||||
}
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
if action != "edit" {
|
||||
"confirmed draft"
|
||||
} else {
|
||||
"confirmed draft edited"
|
||||
}
|
||||
);
|
||||
assert!(chat.input_queue.startup_submission.is_none());
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_does_not_confirm_merged_destination_text() {
|
||||
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.bottom_pane.insert_str("startup draft");
|
||||
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
|
||||
let mut confirmed = true;
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.insert_str("existing draft");
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
"existing draft\nstartup draft"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
+72
-31
@@ -73,6 +73,7 @@ use codex_utils_home_dir::find_codex_home;
|
||||
use codex_utils_oss::ensure_oss_provider_ready;
|
||||
use codex_utils_oss::get_default_model_for_oss_provider;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use cwd_prompt::CwdPromptAction;
|
||||
pub use session_archive_commands::DeleteConfirmation;
|
||||
pub use session_archive_commands::SessionArchiveAction;
|
||||
@@ -200,6 +201,8 @@ mod startup_error;
|
||||
mod startup_hooks_review;
|
||||
mod startup_orchestration;
|
||||
mod startup_preflight;
|
||||
mod startup_presentation;
|
||||
mod startup_recovery;
|
||||
mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
@@ -595,14 +598,26 @@ pub(crate) async fn start_app_server_for_picker(
|
||||
pub(crate) async fn start_embedded_app_server_for_picker(
|
||||
config: &Config,
|
||||
) -> color_eyre::Result<AppServerSession> {
|
||||
let state_db = init_state_db_for_app_server_target(config, &AppServerTarget::Embedded).await?;
|
||||
start_app_server_for_picker(
|
||||
config,
|
||||
&AppServerTarget::Embedded,
|
||||
state_db,
|
||||
let mut target = AppServerTarget::Embedded;
|
||||
let mut state_db = init_state_db_for_app_server_target(config, &target).await?;
|
||||
let app_server = start_app_server(
|
||||
&mut target,
|
||||
Arg0DispatchPaths::default(),
|
||||
config.clone(),
|
||||
Vec::new(),
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
/*strict_config*/ false,
|
||||
CloudConfigBundleLoader::default(),
|
||||
codex_feedback::CodexFeedback::new(),
|
||||
/*log_db*/ None,
|
||||
&mut state_db,
|
||||
Arc::new(EnvironmentManager::default_for_tests()),
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok(
|
||||
AppServerSession::new(app_server, target.thread_params_mode())
|
||||
.with_local_codex_home(&config.codex_home),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1015,6 +1030,7 @@ fn restore_terminal_before_fatal_exit() {
|
||||
if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
|
||||
let _ = tui::restore_after_exit();
|
||||
}
|
||||
startup_recovery::print_unsent_draft();
|
||||
}
|
||||
|
||||
pub async fn run_main(
|
||||
@@ -1024,25 +1040,37 @@ pub async fn run_main(
|
||||
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
system_motion::initialize().await;
|
||||
// Keep the startup future out of the CLI caller's frame while the TUI is running.
|
||||
match Box::pin(startup_orchestration::run_main_inner(
|
||||
cli,
|
||||
arg0_paths,
|
||||
loader_overrides,
|
||||
explicit_remote_endpoint,
|
||||
))
|
||||
startup_recovery::scope(async move {
|
||||
// Startup retains a large future for the whole session. Keep it off callers' stacks,
|
||||
// which also need room to construct a replacement chat widget on `/new`.
|
||||
match Box::pin(startup_orchestration::run_main_inner(
|
||||
cli,
|
||||
arg0_paths,
|
||||
loader_overrides,
|
||||
explicit_remote_endpoint,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}),
|
||||
Err(err) => {
|
||||
restore_terminal_before_fatal_exit();
|
||||
Err(err)
|
||||
}
|
||||
Ok(info) if matches!(&info.exit_reason, ExitReason::Fatal(_)) => {
|
||||
restore_terminal_before_fatal_exit();
|
||||
Ok(info)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1429,11 +1457,13 @@ async fn run_ratatui_app(
|
||||
let Some(app_server) = app_server.take() else {
|
||||
unreachable!("app server should be initialized for --fork picker");
|
||||
};
|
||||
let picker_local_settings =
|
||||
crate::local_settings::LocalSettings::for_tui(&config, &tui);
|
||||
match resume_picker::run_fork_picker_with_app_server(
|
||||
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
|
||||
&mut tui,
|
||||
&config,
|
||||
&crate::local_settings::LocalSettings::from(&config),
|
||||
&picker_local_settings,
|
||||
cli.fork_show_all,
|
||||
app_server,
|
||||
)
|
||||
@@ -1526,11 +1556,12 @@ async fn run_ratatui_app(
|
||||
let Some(app_server) = app_server.take() else {
|
||||
unreachable!("app server should be initialized for --resume picker");
|
||||
};
|
||||
let picker_local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui);
|
||||
match resume_picker::run_resume_picker_with_app_server(
|
||||
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
|
||||
&mut tui,
|
||||
&config,
|
||||
&crate::local_settings::LocalSettings::from(&config),
|
||||
&picker_local_settings,
|
||||
cli.resume_show_all,
|
||||
cli.resume_include_non_interactive,
|
||||
app_server,
|
||||
@@ -1809,7 +1840,21 @@ async fn run_ratatui_app(
|
||||
}
|
||||
startup_draft.apply_config(&config);
|
||||
|
||||
let local_settings = crate::local_settings::LocalSettings::from(&config);
|
||||
// Cloud configuration and session selection can change screen policy after first paint.
|
||||
let use_alt_screen = determine_alt_screen_mode(cli.no_alt_screen, config.tui_alternate_screen);
|
||||
let mode = crate::transcript_mode::TranscriptMode::resolve(
|
||||
config.features.enabled(Feature::TranscriptV2),
|
||||
use_alt_screen,
|
||||
);
|
||||
if use_alt_screen != tui.is_alt_screen_enabled() || mode.is_owned() != tui.is_owned_screen() {
|
||||
std::io::stdout().sync_update(|_| {
|
||||
tui.set_alt_screen_enabled(use_alt_screen);
|
||||
tui.set_owned_screen(mode.is_owned())?;
|
||||
startup_draft.redraw_if_visible(&mut tui)
|
||||
})??;
|
||||
}
|
||||
|
||||
let local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui);
|
||||
// Configure syntax highlighting theme from the final config — onboarding
|
||||
// and resume/fork can both reload config with a different tui_theme, so
|
||||
// this must happen after the last possible reload.
|
||||
@@ -1830,15 +1875,11 @@ async fn run_ratatui_app(
|
||||
let Cli {
|
||||
prompt,
|
||||
shared,
|
||||
no_alt_screen,
|
||||
daemon_cli_executable,
|
||||
..
|
||||
} = cli;
|
||||
let images = shared.into_inner().images;
|
||||
|
||||
let use_alt_screen =
|
||||
determine_alt_screen_mode(no_alt_screen, local_settings.tui.alternate_screen);
|
||||
tui.set_alt_screen_enabled(use_alt_screen);
|
||||
// Persistent app-server resumes may attach to an already-running thread,
|
||||
// where resume config overrides are ignored.
|
||||
let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded)
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/startup_draft_layout_tests.rs
|
||||
expression: startup_layout
|
||||
---
|
||||
cursor=Some((13, 12))
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
› first line
|
||||
second line
|
||||
|
||||
|
||||
Waiting for startup · esc cancel
|
||||
+141
-182
@@ -1,4 +1,5 @@
|
||||
//! Display an editable, non-submitting composer while startup work continues.
|
||||
//! Keep the first composer editable and bottom-anchored while startup work continues.
|
||||
//! Submit keys confirm one draft locally; session dispatch waits for the protected handoff.
|
||||
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
@@ -8,10 +9,7 @@ use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
@@ -31,7 +29,6 @@ use crate::bottom_pane::ChatComposerConfig;
|
||||
use crate::bottom_pane::ComposerDraftSnapshot;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::render::Insets;
|
||||
@@ -49,6 +46,23 @@ use crate::version::CODEX_CLI_VERSION;
|
||||
const STARTUP_EVENT_BATCH_SIZE: usize = 64;
|
||||
const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120);
|
||||
|
||||
#[path = "startup_draft_layout.rs"]
|
||||
mod layout;
|
||||
|
||||
#[path = "startup_draft_input.rs"]
|
||||
mod input;
|
||||
#[cfg(test)]
|
||||
use input::handle_startup_draft_key;
|
||||
|
||||
/// Locally resolved presentation used before the first editable frame.
|
||||
pub(crate) struct StartupScreen {
|
||||
pub(crate) use_alt_screen: bool,
|
||||
pub(crate) transcript_mode: crate::transcript_mode::TranscriptMode,
|
||||
pub(crate) status_line_enabled: bool,
|
||||
pub(crate) keymap: RuntimeKeymap,
|
||||
pub(crate) disable_paste_burst: bool,
|
||||
}
|
||||
|
||||
/// Identifies the first interactive surface expected for the current invocation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum StartupDraftInitialScreen {
|
||||
@@ -61,7 +75,6 @@ pub(crate) enum StartupDraftInitialScreen {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum StartupDraftSessionAction {
|
||||
New,
|
||||
/// Keep in-app creation alive while allowing draft edits until setup finishes.
|
||||
NewFromCommandCenter,
|
||||
Resume,
|
||||
Fork,
|
||||
@@ -87,7 +100,7 @@ pub(crate) struct StartupDraft {
|
||||
pump: StartupDraftPump,
|
||||
}
|
||||
|
||||
/// Keeps the existing terminal responsive without owning it or permitting startup submission.
|
||||
/// Keeps terminal input responsive and carries one submission intent into the live session.
|
||||
pub(crate) struct StartupDraftPump {
|
||||
header: Box<dyn HistoryCell>,
|
||||
bottom_pane: BottomPane,
|
||||
@@ -95,31 +108,50 @@ pub(crate) struct StartupDraftPump {
|
||||
app_event_rx: UnboundedReceiver<AppEvent>,
|
||||
initial_screen: StartupDraftInitialScreen,
|
||||
session_action: StartupDraftSessionAction,
|
||||
resolved_selection: Option<SessionSelection>,
|
||||
configured_cwd: Option<PathBuf>,
|
||||
pending_paste_newline: Option<(Instant, String)>,
|
||||
submission_pending: bool,
|
||||
key_chord_matcher: crate::keymap::KeyChordMatcher,
|
||||
key_chords: std::sync::Arc<crate::keymap::RuntimeChordKeymap>,
|
||||
}
|
||||
|
||||
impl StartupDraft {
|
||||
/// Initialize the terminal without showing a composer before an expected session picker.
|
||||
/// Adopt the initialized terminal and apply editing policy before the first composer paint.
|
||||
pub(crate) fn new(
|
||||
mut initialized_terminal: tui::InitializedTerminal,
|
||||
terminal_restore_guard: TerminalRestoreGuard,
|
||||
initial_screen: StartupDraftInitialScreen,
|
||||
session_action: StartupDraftSessionAction,
|
||||
screen: StartupScreen,
|
||||
) -> io::Result<Self> {
|
||||
let mut initialized_terminal = tui::init()?;
|
||||
let terminal_restore_guard = TerminalRestoreGuard::new();
|
||||
initialized_terminal.terminal.clear()?;
|
||||
|
||||
let tui = Tui::new(
|
||||
let mut tui = Tui::new(
|
||||
initialized_terminal.terminal,
|
||||
initialized_terminal.enhanced_keys_supported,
|
||||
initialized_terminal.stderr_guard,
|
||||
);
|
||||
let pump = StartupDraftPump::new(&tui, initial_screen, session_action);
|
||||
tui.set_alt_screen_enabled(screen.use_alt_screen);
|
||||
let mut pump = StartupDraftPump::new(&tui, initial_screen, session_action);
|
||||
pump.bottom_pane
|
||||
.set_status_line_enabled(screen.status_line_enabled);
|
||||
pump.bottom_pane.set_keymap_bindings(&screen.keymap);
|
||||
pump.bottom_pane
|
||||
.set_disable_paste_burst(screen.disable_paste_burst);
|
||||
pump.key_chords = screen.keymap.chords;
|
||||
let mut draft = Self {
|
||||
tui,
|
||||
terminal_restore_guard,
|
||||
pump,
|
||||
};
|
||||
draft.pump.show_initial_screen(&mut draft.tui)?;
|
||||
// Present the screen switch and its first composer frame together.
|
||||
std::io::stdout().sync_update(|_| {
|
||||
draft
|
||||
.tui
|
||||
.set_owned_screen(screen.transcript_mode.is_owned())?;
|
||||
draft.pump.redraw_if_visible(&mut draft.tui)
|
||||
})??;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
@@ -143,6 +175,7 @@ impl StartupDraft {
|
||||
|
||||
/// Lend the original terminal to an existing interactive startup screen.
|
||||
pub(crate) fn tui_mut(&mut self) -> &mut Tui {
|
||||
self.pump.key_chord_matcher.cancel();
|
||||
&mut self.tui
|
||||
}
|
||||
|
||||
@@ -171,9 +204,19 @@ impl StartupDraftPump {
|
||||
initial_screen,
|
||||
session_action,
|
||||
pending_paste_newline: None,
|
||||
resolved_selection: None,
|
||||
configured_cwd: None,
|
||||
submission_pending: false,
|
||||
key_chord_matcher: Default::default(),
|
||||
key_chords: RuntimeKeymap::defaults().chords,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot {
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
self.bottom_pane.composer_draft_snapshot()
|
||||
}
|
||||
|
||||
/// Keep a provisional composer responsive when the caller has one to display.
|
||||
pub(crate) async fn run_with_optional_draft<F, T, E>(
|
||||
draft: Option<&mut Self>,
|
||||
@@ -192,17 +235,39 @@ impl StartupDraftPump {
|
||||
|
||||
/// Refresh the session header and safe editor shortcuts without enabling modal editing.
|
||||
pub(crate) fn apply_config(&mut self, config: &Config) {
|
||||
if self
|
||||
.configured_cwd
|
||||
.as_deref()
|
||||
.is_some_and(|cwd| cwd != config.cwd.as_path())
|
||||
{
|
||||
self.cancel_submission();
|
||||
}
|
||||
self.configured_cwd = Some(config.cwd.to_path_buf());
|
||||
let local_settings = crate::local_settings::LocalSettings::from(config);
|
||||
self.header = startup_session_header(Some(config));
|
||||
self.bottom_pane.set_status_line_enabled(
|
||||
local_settings
|
||||
.tui
|
||||
.status_line
|
||||
.as_ref()
|
||||
.is_none_or(|items| !items.is_empty()),
|
||||
);
|
||||
self.bottom_pane
|
||||
.set_disable_paste_burst(local_settings.tui.disable_paste_burst.unwrap_or(false));
|
||||
self.bottom_pane.request_redraw();
|
||||
if let Ok(keymap) = RuntimeKeymap::from_config(&local_settings.tui.keymap) {
|
||||
if crate::keymap::keymap_action_id("composer", "submit").is_none_or(|submit| {
|
||||
self.key_chords.configured_specs(submit) != keymap.chords.configured_specs(submit)
|
||||
}) {
|
||||
self.cancel_submission();
|
||||
}
|
||||
self.bottom_pane.set_keymap_bindings(&keymap);
|
||||
self.key_chord_matcher.cancel();
|
||||
self.key_chords = keymap.chords;
|
||||
}
|
||||
}
|
||||
|
||||
/// Align the provisional loading message with the resolved session selection.
|
||||
/// Align the loading message and revoke confirmation when startup changes its destination.
|
||||
pub(crate) fn update_session_selection(
|
||||
&mut self,
|
||||
tui: &mut Tui,
|
||||
@@ -215,9 +280,31 @@ impl StartupDraftPump {
|
||||
SessionSelection::Resume(_) => StartupDraftSessionAction::Resume,
|
||||
SessionSelection::Fork(_) => StartupDraftSessionAction::Fork,
|
||||
};
|
||||
if self.session_action == session_action {
|
||||
return Ok(());
|
||||
let destination_changed = match self.resolved_selection.as_ref() {
|
||||
Some(SessionSelection::StartFresh) => {
|
||||
!matches!(session_selection, SessionSelection::StartFresh)
|
||||
}
|
||||
Some(SessionSelection::AgentsOverview) => {
|
||||
!matches!(session_selection, SessionSelection::AgentsOverview)
|
||||
}
|
||||
Some(SessionSelection::Exit) => !matches!(session_selection, SessionSelection::Exit),
|
||||
Some(SessionSelection::Resume(previous)) => {
|
||||
!matches!(session_selection, SessionSelection::Resume(next) if previous.thread_id == next.thread_id && previous.cwd == next.cwd)
|
||||
}
|
||||
Some(SessionSelection::Fork(previous)) => {
|
||||
!matches!(session_selection, SessionSelection::Fork(next) if previous.thread_id == next.thread_id && previous.cwd == next.cwd)
|
||||
}
|
||||
None => self.session_action != session_action,
|
||||
};
|
||||
if destination_changed
|
||||
|| matches!(
|
||||
session_selection,
|
||||
SessionSelection::AgentsOverview | SessionSelection::Exit
|
||||
)
|
||||
{
|
||||
self.cancel_submission();
|
||||
}
|
||||
self.resolved_selection = Some(session_selection.clone());
|
||||
self.session_action = session_action;
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.draw(tui, tui.terminal.last_known_screen_size)?;
|
||||
@@ -289,16 +376,24 @@ impl StartupDraftPump {
|
||||
|
||||
/// Preserve the editable draft, its cursor, and any pending large-paste placeholders.
|
||||
pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot {
|
||||
self.take_draft()
|
||||
let draft = self.take_draft();
|
||||
crate::startup_recovery::remember(draft.clone());
|
||||
draft
|
||||
}
|
||||
|
||||
pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot {
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
self.bottom_pane.composer_draft_snapshot()
|
||||
/// Transfer the single confirmed submission without removing its visible draft.
|
||||
pub(crate) fn take_submission_intent(&mut self) -> bool {
|
||||
std::mem::take(&mut self.submission_pending)
|
||||
}
|
||||
|
||||
/// Draw the initial composer only when no protected startup screen must appear first.
|
||||
fn show_initial_screen(&mut self, tui: &mut Tui) -> io::Result<()> {
|
||||
fn cancel_submission(&mut self) {
|
||||
if std::mem::take(&mut self.submission_pending) {
|
||||
self.bottom_pane.set_footer_hint_override(/*items*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraw the composer without revealing it while a protected startup screen owns input.
|
||||
pub(crate) fn redraw_if_visible(&mut self, tui: &mut Tui) -> io::Result<()> {
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.show(tui)?;
|
||||
}
|
||||
@@ -311,113 +406,28 @@ impl StartupDraftPump {
|
||||
self.draw(tui, tui.terminal.last_known_screen_size)
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> {
|
||||
let screen_size = tui.screen_size_for_event(&event)?;
|
||||
if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() {
|
||||
let continues_paste = match &event {
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Char(_),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => !key_hint::has_ctrl_or_alt(*modifiers),
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) if modifiers.is_empty() => {
|
||||
if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
|
||||
newlines.push('\n');
|
||||
self.pending_paste_newline = Some((Instant::now(), newlines));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::Paste(text) => !text.is_empty(),
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {
|
||||
self.pending_paste_newline = Some((started_at, newlines));
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.draw(tui, screen_size)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::Mouse(_) | TuiEvent::FocusLost => {
|
||||
self.pending_paste_newline = Some((started_at, newlines));
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::Key(_) => false,
|
||||
};
|
||||
if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
|
||||
self.bottom_pane.handle_paste(newlines);
|
||||
}
|
||||
}
|
||||
match event {
|
||||
TuiEvent::Key(key) => {
|
||||
if self.initial_screen != StartupDraftInitialScreen::Composer
|
||||
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
|
||||
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if key.code == KeyCode::Enter
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.bottom_pane.is_in_paste_burst()
|
||||
{
|
||||
let text_len = self.bottom_pane.composer_text().len();
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
if self
|
||||
.bottom_pane
|
||||
.composer_text()
|
||||
.len()
|
||||
.saturating_sub(text_len)
|
||||
> 1
|
||||
{
|
||||
self.pending_paste_newline = Some((Instant::now(), "\n".to_string()));
|
||||
}
|
||||
}
|
||||
match handle_startup_draft_key(&mut self.bottom_pane, key) {
|
||||
Err(error) if StartupCancelled::matches(&error) => {
|
||||
// In-app setup owns a live creation request. Keep pumping input
|
||||
// instead of abandoning it or blocking on cancellation cleanup.
|
||||
if self.session_action != StartupDraftSessionAction::NewFromCommandCenter {
|
||||
if let Err(clear_error) = tui.terminal.clear() {
|
||||
tracing::warn!(
|
||||
error = %clear_error,
|
||||
"failed to clear the cancelled startup composer"
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
|
||||
TuiEvent::Mouse(_) | TuiEvent::FocusLost => return Ok(()),
|
||||
}
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.draw(tui, screen_size)?;
|
||||
}
|
||||
while self.app_event_rx.try_recv().is_ok() {}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self, tui: &mut Tui, screen_size: Size) -> io::Result<()> {
|
||||
let _ = self.bottom_pane.flush_paste_burst_if_due();
|
||||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
|
||||
}
|
||||
if self.bottom_pane.is_in_paste_burst() {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(ChatComposer::recommended_paste_flush_delay());
|
||||
}
|
||||
self.bottom_pane.pre_draw_tick();
|
||||
let renderable =
|
||||
startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action);
|
||||
let desired_height = renderable.desired_height(screen_size.width);
|
||||
let owned = tui.is_owned_screen();
|
||||
let owned_layout =
|
||||
layout::OwnedStartupLayout::new(&self.header, &self.bottom_pane, self.session_action);
|
||||
let renderable = if owned {
|
||||
RenderableItem::Borrowed(&owned_layout)
|
||||
} else {
|
||||
startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action)
|
||||
};
|
||||
let desired_height = if owned {
|
||||
screen_size.height
|
||||
} else {
|
||||
renderable.desired_height(screen_size.width)
|
||||
};
|
||||
tui.draw_with_resize_reflow(desired_height, screen_size, |frame| {
|
||||
let area = frame.area();
|
||||
renderable.render(area, frame.buffer);
|
||||
@@ -425,66 +435,11 @@ impl StartupDraftPump {
|
||||
frame.set_cursor_style(renderable.cursor_style(area));
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
})
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_startup_draft_key(bottom_pane: &mut BottomPane, key: KeyEvent) -> io::Result<()> {
|
||||
let _ = bottom_pane.flush_paste_burst_if_due();
|
||||
if key.kind == KeyEventKind::Release
|
||||
|| key.code == KeyCode::Enter && key.modifiers.is_empty()
|
||||
|| matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|
||||
|| bottom_pane.is_startup_composer_action(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers);
|
||||
if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) {
|
||||
return Ok(());
|
||||
}
|
||||
let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key);
|
||||
let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key);
|
||||
if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) {
|
||||
bottom_pane.flush_composer_paste_burst();
|
||||
if bottom_pane.composer_is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled));
|
||||
}
|
||||
if is_ctrl_c {
|
||||
bottom_pane.on_ctrl_c();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_hint::has_ctrl_or_alt(key.modifiers) && !bottom_pane.is_safe_startup_editor_key(key)
|
||||
|| key.code == KeyCode::Enter && !bottom_pane.is_safe_startup_editor_key(key)
|
||||
|| key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|
||||
|| !matches!(
|
||||
key.code,
|
||||
KeyCode::Char(_)
|
||||
| KeyCode::Enter
|
||||
| KeyCode::Esc
|
||||
| KeyCode::Left
|
||||
| KeyCode::Right
|
||||
| KeyCode::Up
|
||||
| KeyCode::Down
|
||||
| KeyCode::Home
|
||||
| KeyCode::End
|
||||
| KeyCode::Backspace
|
||||
| KeyCode::Delete
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = bottom_pane.handle_key_event(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn startup_session_header(config: Option<&Config>) -> Box<dyn HistoryCell> {
|
||||
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
|
||||
let directory = config.map_or_else(
|
||||
@@ -525,7 +480,7 @@ fn startup_draft_renderable<'a>(
|
||||
renderable.push(
|
||||
/*flex*/ 0,
|
||||
bottom_pane
|
||||
.as_renderable_with_options(Default::default())
|
||||
.as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions::default())
|
||||
.inset(Insets::tlbr(
|
||||
/*top*/ u16::from(loading_message.is_none()),
|
||||
/*left*/ 0,
|
||||
@@ -561,3 +516,7 @@ fn startup_draft_bottom_pane(
|
||||
#[cfg(test)]
|
||||
#[path = "startup_draft_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_draft_submission_tests.rs"]
|
||||
mod submission_tests;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Edit and confirm the provisional startup draft without executing actions before initialization.
|
||||
//!
|
||||
//! Terminal input stays with the original pump through startup. Paste-newline ambiguity is
|
||||
//! preserved, protected screens do not consume draft input, and confirmation never removes text.
|
||||
|
||||
use std::io;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
use super::STARTUP_PASTE_NEWLINE_TIMEOUT;
|
||||
use super::StartupCancelled;
|
||||
use super::StartupDraftInitialScreen;
|
||||
use super::StartupDraftPump;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::key_hint;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
|
||||
impl StartupDraftPump {
|
||||
pub(super) fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> {
|
||||
let screen_size = tui.screen_size_for_event(&event)?;
|
||||
if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() {
|
||||
let continues_paste = match &event {
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Char(_),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => !key_hint::has_ctrl_or_alt(*modifiers),
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) if modifiers.is_empty() => {
|
||||
if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
|
||||
newlines.push('\n');
|
||||
self.pending_paste_newline = Some((Instant::now(), newlines));
|
||||
return Ok(());
|
||||
}
|
||||
false
|
||||
}
|
||||
TuiEvent::Paste(text) => !text.is_empty(),
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {
|
||||
self.pending_paste_newline = Some((started_at, newlines));
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.draw(tui, screen_size)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::FocusLost | TuiEvent::Mouse(_) => {
|
||||
self.pending_paste_newline = Some((started_at, newlines));
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::Key(_) => false,
|
||||
};
|
||||
if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
|
||||
self.bottom_pane.handle_paste(newlines);
|
||||
}
|
||||
}
|
||||
match event {
|
||||
TuiEvent::Key(key) => {
|
||||
if self.initial_screen != StartupDraftInitialScreen::Composer
|
||||
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
|
||||
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let key = match self.key_chord_matcher.advance(
|
||||
key,
|
||||
&self.key_chords,
|
||||
self.bottom_pane.keymap_contexts(),
|
||||
) {
|
||||
crate::keymap::KeyChordMatch::Completed(key) => key,
|
||||
crate::keymap::KeyChordMatch::PassThrough => key,
|
||||
crate::keymap::KeyChordMatch::Pending(_)
|
||||
| crate::keymap::KeyChordMatch::Cancelled
|
||||
| crate::keymap::KeyChordMatch::Ignored => return Ok(()),
|
||||
};
|
||||
if key.code == KeyCode::Enter
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.bottom_pane.is_in_paste_burst()
|
||||
{
|
||||
let text_len = self.bottom_pane.composer_text().len();
|
||||
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))
|
||||
{
|
||||
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.bottom_pane.is_startup_submit_key(key)
|
||||
{
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
if self.pending_paste_newline.is_none()
|
||||
&& !self.bottom_pane.composer_text().trim().is_empty()
|
||||
{
|
||||
self.submission_pending = true;
|
||||
self.bottom_pane.set_footer_hint_override(Some(vec![
|
||||
("Waiting for startup".into(), String::new()),
|
||||
("esc".into(), "cancel".into()),
|
||||
]));
|
||||
}
|
||||
crate::startup_recovery::remember(
|
||||
self.bottom_pane.composer_recovery_snapshot(),
|
||||
);
|
||||
self.draw(tui, screen_size)?;
|
||||
return Ok(());
|
||||
}
|
||||
if self.submission_pending
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
&& key.code == KeyCode::Esc
|
||||
{
|
||||
self.cancel_submission();
|
||||
self.draw(tui, screen_size)?;
|
||||
return Ok(());
|
||||
}
|
||||
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
|
||||
&& StartupCancelled::matches(&error))
|
||||
{
|
||||
if StartupCancelled::matches(&error)
|
||||
&& let Err(clear_error) = tui.terminal.clear()
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %clear_error,
|
||||
"failed to clear the cancelled startup composer"
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if self.bottom_pane.composer_text() != previous_text
|
||||
|| self.bottom_pane.is_in_paste_burst()
|
||||
{
|
||||
self.cancel_submission();
|
||||
}
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
self.key_chord_matcher.cancel();
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.cancel_submission();
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
|
||||
TuiEvent::FocusLost => {
|
||||
self.key_chord_matcher.cancel();
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::Mouse(_) => return Ok(()),
|
||||
}
|
||||
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.draw(tui, screen_size)?;
|
||||
}
|
||||
while self.app_event_rx.try_recv().is_ok() {}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_startup_draft_key(
|
||||
bottom_pane: &mut BottomPane,
|
||||
key: KeyEvent,
|
||||
) -> io::Result<()> {
|
||||
let _ = bottom_pane.flush_paste_burst_if_due();
|
||||
if key.kind == KeyEventKind::Release
|
||||
|| key.code == KeyCode::Enter
|
||||
&& key.modifiers.is_empty()
|
||||
&& !bottom_pane.is_safe_startup_editor_key(key)
|
||||
|| matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|
||||
|| bottom_pane.is_startup_composer_action(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers);
|
||||
if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) {
|
||||
return Ok(());
|
||||
}
|
||||
let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key);
|
||||
let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key);
|
||||
if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) {
|
||||
bottom_pane.flush_composer_paste_burst();
|
||||
if bottom_pane.composer_is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled));
|
||||
}
|
||||
if is_ctrl_c {
|
||||
bottom_pane.on_ctrl_c();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_hint::has_ctrl_or_alt(key.modifiers) && !bottom_pane.is_safe_startup_editor_key(key)
|
||||
|| key.code == KeyCode::Enter && !bottom_pane.is_safe_startup_editor_key(key)
|
||||
|| key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|
||||
|| !bottom_pane.is_safe_startup_editor_key(key)
|
||||
&& !matches!(
|
||||
key.code,
|
||||
KeyCode::Char(_)
|
||||
| KeyCode::Enter
|
||||
| KeyCode::Esc
|
||||
| KeyCode::Left
|
||||
| KeyCode::Right
|
||||
| KeyCode::Up
|
||||
| KeyCode::Down
|
||||
| KeyCode::Home
|
||||
| KeyCode::End
|
||||
| KeyCode::Backspace
|
||||
| KeyCode::Delete
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = bottom_pane.handle_key_event(key);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! First-frame owned layout: the banner stays at the top and the normal composer at the bottom.
|
||||
//! Measurement, paint, and cursor placement use the same bottom rectangle, including both footers.
|
||||
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
use super::StartupDraftSessionAction;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::CommandPopupPlacement;
|
||||
use crate::bottom_pane::ComposerRenderOptions;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
|
||||
pub(super) struct OwnedStartupLayout<'a> {
|
||||
header: &'a dyn Renderable,
|
||||
bottom: RenderableItem<'a>,
|
||||
session_action: StartupDraftSessionAction,
|
||||
}
|
||||
|
||||
impl<'a> OwnedStartupLayout<'a> {
|
||||
pub(super) fn new(
|
||||
header: &'a dyn Renderable,
|
||||
bottom_pane: &'a BottomPane,
|
||||
session_action: StartupDraftSessionAction,
|
||||
) -> Self {
|
||||
Self {
|
||||
header,
|
||||
bottom: bottom_pane.as_renderable_with_options(ComposerRenderOptions {
|
||||
separate_status_line: true,
|
||||
command_popup_placement: CommandPopupPlacement::Overlay,
|
||||
..ComposerRenderOptions::default()
|
||||
}),
|
||||
session_action,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bottom_area(&self, area: Rect) -> Rect {
|
||||
let height = self.bottom.desired_height(area.width).min(area.height);
|
||||
Rect {
|
||||
y: area.bottom().saturating_sub(height),
|
||||
height,
|
||||
..area
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for OwnedStartupLayout<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let bottom = self.bottom_area(area);
|
||||
let header = Rect {
|
||||
height: self
|
||||
.header
|
||||
.desired_height(area.width)
|
||||
.min(bottom.y.saturating_sub(area.y)),
|
||||
..area
|
||||
};
|
||||
self.header.render(header, buf);
|
||||
let message = match self.session_action {
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => {
|
||||
None
|
||||
}
|
||||
StartupDraftSessionAction::Resume => Some(" Resuming session…"),
|
||||
StartupDraftSessionAction::Fork => Some(" Forking session…"),
|
||||
};
|
||||
if let Some(message) = message
|
||||
&& header.bottom() < bottom.y
|
||||
{
|
||||
message.dim().render(
|
||||
Rect {
|
||||
y: header.bottom(),
|
||||
height: 1,
|
||||
..area
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
self.bottom.render(bottom, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.header
|
||||
.desired_height(width)
|
||||
.saturating_add(self.bottom.desired_height(width))
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.bottom.cursor_pos(self.bottom_area(area))
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
self.bottom.cursor_style(self.bottom_area(area))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_draft_layout_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Verify provisional composer geometry while startup owns the terminal.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn owned_startup_keeps_the_live_bottom_geometry() {
|
||||
let mut pump = crate::startup_draft::tests::quiet_startup_test_pump();
|
||||
pump.bottom_pane.set_status_line_enabled(/*enabled*/ true);
|
||||
pump.bottom_pane
|
||||
.set_composer_text("first line\nsecond line".into(), Vec::new(), Vec::new());
|
||||
pump.bottom_pane.set_footer_hint_override(Some(vec![
|
||||
("Waiting for startup".into(), String::new()),
|
||||
("esc".into(), "cancel".into()),
|
||||
]));
|
||||
let layout = OwnedStartupLayout::new(
|
||||
&pump.header,
|
||||
&pump.bottom_pane,
|
||||
StartupDraftSessionAction::New,
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
layout.render(area, &mut buffer);
|
||||
let frame = (area.y..area.bottom())
|
||||
.map(|y| {
|
||||
(area.x..area.right())
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(
|
||||
"owned_startup_layout",
|
||||
format!("cursor={:?}\n{frame}", layout.cursor_pos(area))
|
||||
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Verify confirmation follows the visible draft and its selected destination.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_enter_confirms_one_visible_draft_and_escape_cancels() {
|
||||
let mut pump = tests::quiet_startup_test_pump();
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.bottom_pane.insert_str("keep this startup draft");
|
||||
let draft = pump.bottom_pane.composer_draft_snapshot();
|
||||
for _ in 0..3 {
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("confirm startup draft");
|
||||
}
|
||||
assert!(pump.submission_pending);
|
||||
assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft);
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Esc)))
|
||||
.expect("cancel startup submission");
|
||||
assert!(!pump.take_submission_intent());
|
||||
assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft);
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("confirm again");
|
||||
pump.handle_event(&mut tui, TuiEvent::Paste(" with more text".into()))
|
||||
.expect("edit confirmed startup draft");
|
||||
assert!(!pump.take_submission_intent());
|
||||
assert_eq!(
|
||||
pump.bottom_pane.composer_text(),
|
||||
"keep this startup draft with more text"
|
||||
);
|
||||
let draft = pump.bottom_pane.composer_draft_snapshot();
|
||||
pump.session_action = StartupDraftSessionAction::NewFromCommandCenter;
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("command-center startup stays edit-only");
|
||||
assert!(!pump.take_submission_intent());
|
||||
assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft);
|
||||
pump.bottom_pane
|
||||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||||
pump.events = Box::pin(futures::stream::iter(['c', 'd'].map(|character| {
|
||||
TuiEvent::Key(KeyEvent::new(
|
||||
KeyCode::Char(character),
|
||||
KeyModifiers::CONTROL,
|
||||
))
|
||||
})));
|
||||
pump.flush_pending_events(&mut tui)
|
||||
.await
|
||||
.expect("command-center startup survives cancellation during the final input drain");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_honors_configured_submit_binding() {
|
||||
let mut pump = tests::quiet_startup_test_pump();
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
let config = toml::from_str(
|
||||
"[composer]\nsubmit = \"ctrl-x s\"\n[editor]\ninsert_newline = \"enter\"\nmove_line_start = \"ctrl-x a\"\n"
|
||||
)
|
||||
.unwrap();
|
||||
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
|
||||
pump.bottom_pane.set_keymap_bindings(&keymap);
|
||||
pump.key_chords = keymap.chords;
|
||||
for code in [
|
||||
KeyCode::Char('a'),
|
||||
KeyCode::Char('b'),
|
||||
KeyCode::Enter,
|
||||
KeyCode::Char('c'),
|
||||
] {
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(code)))
|
||||
.expect("rebound Enter inserts one newline after a paste burst");
|
||||
}
|
||||
pump.bottom_pane.flush_composer_paste_burst();
|
||||
assert_eq!(pump.bottom_pane.composer_text(), "ab\nc");
|
||||
pump.bottom_pane
|
||||
.set_composer_text("draft".into(), Vec::new(), Vec::new());
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyEvent::from(KeyCode::Char('a')),
|
||||
] {
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(key))
|
||||
.expect("configured editor chord");
|
||||
}
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("Enter does not submit");
|
||||
assert!(!pump.submission_pending);
|
||||
let draft = pump.bottom_pane.composer_draft_snapshot();
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyEvent::from(KeyCode::Char('s')),
|
||||
] {
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(key))
|
||||
.expect("configured submit");
|
||||
}
|
||||
assert!(pump.take_submission_intent());
|
||||
assert_eq!(pump.into_draft(), draft);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_accepts_enter_after_paste_ambiguity_expires() {
|
||||
let mut pump = tests::quiet_startup_test_pump();
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.bottom_pane.insert_str("pasted draft");
|
||||
pump.pending_paste_newline = Some((
|
||||
Instant::now() - STARTUP_PASTE_NEWLINE_TIMEOUT - Duration::from_millis(/*millis*/ 1),
|
||||
"\n".into(),
|
||||
));
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("confirm after the ambiguous paste has finished");
|
||||
assert!(pump.take_submission_intent());
|
||||
assert_eq!(pump.into_draft().text, "pasted draft");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_redirect_to_agents_preserves_only_editable_text() {
|
||||
for selection in [SessionSelection::AgentsOverview, SessionSelection::Exit] {
|
||||
let mut pump = tests::quiet_startup_test_pump();
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.update_session_selection(&mut tui, &SessionSelection::StartFresh)
|
||||
.expect("resolve initial destination");
|
||||
pump.bottom_pane
|
||||
.insert_str("keep this draft in its original context");
|
||||
let draft = pump.bottom_pane.composer_draft_snapshot();
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("confirm startup draft");
|
||||
pump.update_session_selection(&mut tui, &selection)
|
||||
.expect("redirect away from startup destination");
|
||||
assert!(!pump.take_submission_intent());
|
||||
assert_eq!(pump.into_draft(), draft);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_submission_thread_change_preserves_only_editable_text() {
|
||||
let mut pump = tests::quiet_startup_test_pump();
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
for destination in [
|
||||
codex_protocol::ThreadId::new(),
|
||||
codex_protocol::ThreadId::new(),
|
||||
] {
|
||||
let selection = SessionSelection::Resume(crate::resume_picker::SessionTarget {
|
||||
path: None,
|
||||
thread_id: destination,
|
||||
cwd: None,
|
||||
history_mode: None,
|
||||
});
|
||||
pump.update_session_selection(&mut tui, &selection)
|
||||
.expect("select startup destination");
|
||||
assert!(!pump.submission_pending);
|
||||
if pump.bottom_pane.composer_is_empty() {
|
||||
pump.bottom_pane.insert_str("retained draft");
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("confirm first destination");
|
||||
assert!(pump.submission_pending);
|
||||
}
|
||||
}
|
||||
assert!(!pump.take_submission_intent());
|
||||
assert_eq!(pump.into_draft().text, "retained draft");
|
||||
}
|
||||
@@ -38,7 +38,12 @@ where
|
||||
app_event_rx: rx,
|
||||
initial_screen: StartupDraftInitialScreen::Composer,
|
||||
session_action: StartupDraftSessionAction::New,
|
||||
resolved_selection: None,
|
||||
configured_cwd: None,
|
||||
pending_paste_newline: None,
|
||||
submission_pending: false,
|
||||
key_chord_matcher: Default::default(),
|
||||
key_chords: crate::keymap::RuntimeKeymap::defaults().chords,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +185,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() {
|
||||
pump.bottom_pane.insert_str("draft while loading");
|
||||
}
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.show_initial_screen(&mut tui)
|
||||
pump.redraw_if_visible(&mut tui)
|
||||
.expect("respect the initial composer or picker screen");
|
||||
let before = if tui.terminal.viewport_area.is_empty() {
|
||||
"hidden while picker owns input".to_string()
|
||||
@@ -374,6 +379,7 @@ async fn startup_draft_preserves_non_bracketed_multiline_pastes_without_submitti
|
||||
pump.flush_pending_events(&mut tui)
|
||||
.await
|
||||
.expect("preserve multiline non-bracketed paste");
|
||||
assert!(!pump.submission_pending);
|
||||
|
||||
assert_eq!(
|
||||
pump.bottom_pane.composer_text(),
|
||||
@@ -504,8 +510,15 @@ fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() {
|
||||
.expect("use a configured editor movement");
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
|
||||
|
||||
for key in [
|
||||
handle_startup_draft_key(
|
||||
&mut pump.bottom_pane,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
)
|
||||
.expect("honor Enter rebound to local newline editing");
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 1);
|
||||
assert_eq!(pump.bottom_pane.composer_text(), "\nfirst ");
|
||||
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
@@ -528,8 +541,8 @@ fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() {
|
||||
)
|
||||
.expect("ignore configured plain composer actions");
|
||||
}
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
|
||||
assert_eq!(pump.bottom_pane.composer_text(), "first ");
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 1);
|
||||
assert_eq!(pump.bottom_pane.composer_text(), "\nfirst ");
|
||||
assert!(pump.app_event_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
@@ -626,6 +639,16 @@ async fn startup_draft_applies_editor_keymap_without_enabling_vim() {
|
||||
)
|
||||
.expect("honor a configured safe editor shortcut");
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.expect("confirm draft");
|
||||
pump.apply_config(&config);
|
||||
assert!(pump.submission_pending);
|
||||
config.tui_keymap.composer.submit =
|
||||
Some(codex_config::types::KeybindingsSpec::Many(Vec::new()));
|
||||
pump.apply_config(&config);
|
||||
assert!(!pump.submission_pending);
|
||||
assert_eq!(pump.bottom_pane.composer_text(), "draftx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -634,7 +657,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() {
|
||||
let mut composer_tui =
|
||||
crate::tui::test_support::make_test_tui().expect("create composer test terminal");
|
||||
composer_pump
|
||||
.show_initial_screen(&mut composer_tui)
|
||||
.redraw_if_visible(&mut composer_tui)
|
||||
.expect("draw the composer when no protected screen is expected");
|
||||
assert!(!composer_tui.terminal.viewport_area.is_empty());
|
||||
drop(composer_tui);
|
||||
@@ -669,7 +692,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() {
|
||||
StartupDraftInitialScreen::Composer
|
||||
};
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.show_initial_screen(&mut tui)
|
||||
pump.redraw_if_visible(&mut tui)
|
||||
.expect("keep the composer hidden until onboarding finishes");
|
||||
|
||||
pump.flush_pending_events(&mut tui)
|
||||
|
||||
@@ -208,7 +208,79 @@ pub(super) async fn run_main_inner(
|
||||
} else {
|
||||
startup_draft::StartupDraftSessionAction::New
|
||||
};
|
||||
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen, session_action)?;
|
||||
// Local-daemon discovery does not change client config precedence. Resolve explicit
|
||||
// remote selection and the environment without opening a server connection.
|
||||
let presentation_target = app_server_target_for_launch(
|
||||
explicit_remote_endpoint.clone(),
|
||||
/*default_daemon_socket*/ None,
|
||||
reuse_implicit_local_daemon,
|
||||
workload_identity_selected,
|
||||
std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(),
|
||||
)?;
|
||||
let prepared_environment_manager =
|
||||
if should_load_configured_environments(&launch_loader_overrides, &presentation_target) {
|
||||
EnvironmentManager::prepare_from_codex_home(&codex_home).await
|
||||
} else {
|
||||
EnvironmentManager::prepare_from_env().await
|
||||
}
|
||||
.map_err(std::io::Error::other)?;
|
||||
if cli.shared.worktree
|
||||
&& (presentation_target.uses_remote_workspace()
|
||||
|| prepared_environment_manager.default_environment_is_remote())
|
||||
{
|
||||
return Err(std::io::Error::other(
|
||||
"`--worktree` is only supported for local sessions",
|
||||
));
|
||||
}
|
||||
let cwd = cli.cwd.clone();
|
||||
let config_cwd = config_cwd_for_app_server_target(
|
||||
cwd.as_deref(),
|
||||
&presentation_target,
|
||||
prepared_environment_manager.default_environment_is_remote(),
|
||||
)?;
|
||||
// Reuse the profile path resolved above; do not reconstruct profile precedence here.
|
||||
let mut loader_overrides = launch_loader_overrides;
|
||||
loader_overrides.ignore_login_requirements = presentation_target.uses_remote_workspace();
|
||||
let presentation = startup_presentation::load(
|
||||
&cli,
|
||||
&codex_home,
|
||||
loader_overrides.clone(),
|
||||
cli_kv_overrides.clone(),
|
||||
config_cwd,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let Some(config_error) = err
|
||||
.get_ref()
|
||||
.and_then(|inner| inner.downcast_ref::<ConfigLoadError>())
|
||||
{
|
||||
std::io::Error::other(format!(
|
||||
"Error loading config.toml:\n{}",
|
||||
format_config_error_with_source(config_error.config_error())
|
||||
))
|
||||
} else {
|
||||
err
|
||||
}
|
||||
})?;
|
||||
// Keep normal terminal signals available until local configuration is ready. Once raw
|
||||
// mode begins, StartupDraft immediately takes ownership of input.
|
||||
let (initialized_terminal, terminal_restore_guard) = tokio::task::spawn_blocking(|| {
|
||||
tui::init().map(|terminal| (terminal, TerminalRestoreGuard::new()))
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
let startup_presentation::StartupPresentation {
|
||||
bootstrap_config,
|
||||
config_cwd,
|
||||
screen,
|
||||
} = presentation;
|
||||
let mut startup_draft = startup_draft::StartupDraft::new(
|
||||
initialized_terminal,
|
||||
terminal_restore_guard,
|
||||
initial_screen,
|
||||
session_action,
|
||||
screen,
|
||||
)?;
|
||||
|
||||
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
|
||||
startup_draft
|
||||
@@ -233,49 +305,8 @@ pub(super) async fn run_main_inner(
|
||||
arg0_paths.codex_self_exe.clone(),
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?;
|
||||
let prepared_environment_manager =
|
||||
if should_load_configured_environments(&loader_overrides, &app_server_target) {
|
||||
startup_draft
|
||||
.run_until(EnvironmentManager::prepare_from_codex_home(&codex_home))
|
||||
.await?
|
||||
} else {
|
||||
startup_draft
|
||||
.run_until(EnvironmentManager::prepare_from_env())
|
||||
.await?
|
||||
}
|
||||
.map_err(std::io::Error::other)?;
|
||||
if cli.shared.worktree
|
||||
&& (app_server_target.uses_remote_workspace()
|
||||
|| prepared_environment_manager.default_environment_is_remote())
|
||||
{
|
||||
return Err(std::io::Error::other(
|
||||
"`--worktree` is only supported for local sessions",
|
||||
));
|
||||
}
|
||||
let cwd = cli.cwd.clone();
|
||||
let config_cwd = config_cwd_for_app_server_target(
|
||||
cwd.as_deref(),
|
||||
&app_server_target,
|
||||
prepared_environment_manager.default_environment_is_remote(),
|
||||
)?;
|
||||
let mut loader_overrides = loader_overrides;
|
||||
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
|
||||
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
|
||||
loader_overrides.user_config_path = Some(user_config_path);
|
||||
loader_overrides.user_config_profile = Some(profile_v2.clone());
|
||||
}
|
||||
loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace();
|
||||
|
||||
let bootstrap_config = startup_draft
|
||||
.run_until(load_bootstrap_config_or_exit(
|
||||
&codex_home,
|
||||
config_cwd.as_ref(),
|
||||
cli_kv_overrides.clone(),
|
||||
loader_overrides.clone(),
|
||||
strict_config,
|
||||
CloudConfigBundleLoader::default(),
|
||||
))
|
||||
.await?;
|
||||
// The pre-paint bootstrap used these same local/remote config inputs. Reuse it here;
|
||||
// implicit daemon discovery above changes transport, not the client configuration cwd.
|
||||
let screen_reader_result = if !loader_overrides.ignore_user_config {
|
||||
startup_draft
|
||||
.run_until(screen_reader::initialize(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Resolve first-frame screen ownership from the existing client configuration loader.
|
||||
//!
|
||||
//! The caller supplies its resolved profile overrides and configuration cwd, so this path does
|
||||
//! not introduce separate profile or project precedence. Loading excludes cloud fetches and
|
||||
//! returns the bootstrap result for reuse by startup. Final cloud or selected-project settings
|
||||
//! may update screen policy before handing the terminal to the conversation.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigLoadOptions;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::TomlValue;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureConfigSource;
|
||||
use codex_features::FeatureOverrides;
|
||||
use codex_features::Features;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::Cli;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::legacy_core::config::ConfigTomlLoadResult;
|
||||
use crate::legacy_core::config::ManagedFeatures;
|
||||
use crate::legacy_core::config::load_config_toml_with_layer_stack;
|
||||
use crate::startup_draft::StartupScreen;
|
||||
|
||||
/// Screen policy and the already-loaded bootstrap configuration for this exact loading context.
|
||||
pub(super) struct StartupPresentation {
|
||||
pub(super) bootstrap_config: ConfigTomlLoadResult,
|
||||
pub(super) config_cwd: Option<AbsolutePathBuf>,
|
||||
pub(super) screen: StartupScreen,
|
||||
}
|
||||
|
||||
/// Load client presentation settings without session setup or remote app-server connections.
|
||||
///
|
||||
/// `loader_overrides` must already contain the selected profile and remote login-policy choice.
|
||||
/// `config_cwd` must come from the existing target/environment-aware cwd resolver. Reuse the
|
||||
/// returned bootstrap only when these loading inputs still match the later startup context.
|
||||
pub(super) async fn load(
|
||||
cli: &Cli,
|
||||
codex_home: &Path,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
config_cwd: Option<AbsolutePathBuf>,
|
||||
) -> io::Result<StartupPresentation> {
|
||||
let bootstrap_config = load_config_toml_with_layer_stack(
|
||||
codex_home,
|
||||
config_cwd.as_ref(),
|
||||
cli_kv_overrides,
|
||||
ConfigLoadOptions {
|
||||
loader_overrides,
|
||||
strict_config: cli.strict_config,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let config_toml = &bootstrap_config.config_toml;
|
||||
let configured_features = Features::from_sources(
|
||||
FeatureConfigSource {
|
||||
features: config_toml.features.as_ref(),
|
||||
experimental_use_unified_exec_tool: config_toml.experimental_use_unified_exec_tool,
|
||||
},
|
||||
FeatureConfigSource::default(),
|
||||
FeatureOverrides::default(),
|
||||
);
|
||||
// Full configuration construction will publish its normal managed-feature warnings once.
|
||||
let mut startup_warnings = Vec::new();
|
||||
let features = ManagedFeatures::from_configured_with_warnings(
|
||||
configured_features,
|
||||
bootstrap_config
|
||||
.config_layer_stack
|
||||
.requirements()
|
||||
.feature_requirements
|
||||
.clone(),
|
||||
&mut startup_warnings,
|
||||
)?;
|
||||
let alternate_screen = config_toml
|
||||
.tui
|
||||
.as_ref()
|
||||
.map(|tui| tui.alternate_screen)
|
||||
.unwrap_or_default();
|
||||
let use_alt_screen = crate::determine_alt_screen_mode(cli.no_alt_screen, alternate_screen);
|
||||
let transcript_mode = crate::transcript_mode::TranscriptMode::resolve(
|
||||
features.enabled(Feature::TranscriptV2),
|
||||
use_alt_screen,
|
||||
);
|
||||
let status_line_enabled = config_toml
|
||||
.tui
|
||||
.as_ref()
|
||||
.and_then(|tui| tui.status_line.as_ref())
|
||||
.is_none_or(|items| !items.is_empty());
|
||||
let default_tui_settings = codex_config::types::Tui::default();
|
||||
let tui_settings = config_toml.tui.as_ref().unwrap_or(&default_tui_settings);
|
||||
let keymap = RuntimeKeymap::from_config(&tui_settings.keymap).map_err(io::Error::other)?;
|
||||
let disable_paste_burst = tui_settings
|
||||
.disable_paste_burst
|
||||
.or(config_toml.disable_paste_burst)
|
||||
.unwrap_or(/*default*/ false);
|
||||
Ok(StartupPresentation {
|
||||
bootstrap_config,
|
||||
config_cwd,
|
||||
screen: StartupScreen {
|
||||
use_alt_screen,
|
||||
transcript_mode,
|
||||
status_line_enabled,
|
||||
keymap,
|
||||
disable_paste_burst,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_presentation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Check first-frame editor and screen settings using the real client configuration loader.
|
||||
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use crossterm::event::KeyCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_profile_controls_submit_before_the_first_frame() -> anyhow::Result<()> {
|
||||
let home = tempfile::tempdir()?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
"disable_paste_burst = false\n",
|
||||
)?;
|
||||
let selected = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?;
|
||||
let cli = Cli::try_parse_from(["codex"])?;
|
||||
for (submit, expected_bindings) in [
|
||||
("[]", Vec::new()),
|
||||
("\"f12\"", vec![crate::key_hint::plain(KeyCode::F(12))]),
|
||||
] {
|
||||
std::fs::write(
|
||||
&selected,
|
||||
format!("disable_paste_burst = true\n[tui.keymap.composer]\nsubmit = {submit}\n"),
|
||||
)?;
|
||||
let presentation = load(
|
||||
&cli,
|
||||
home.path(),
|
||||
LoaderOverrides {
|
||||
user_config_path: Some(selected.clone()),
|
||||
user_config_profile: Some("work".parse()?),
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
},
|
||||
Vec::new(),
|
||||
/*config_cwd*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(
|
||||
presentation.screen.keymap.composer.submit,
|
||||
presentation.screen.disable_paste_burst,
|
||||
),
|
||||
(expected_bindings, true),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_frame_respects_screen_and_status_line_overrides() -> anyhow::Result<()> {
|
||||
let home = tempfile::tempdir()?;
|
||||
for (config, args, expected) in [
|
||||
(
|
||||
"[features]\ntranscript_v2 = true\n",
|
||||
vec!["codex"],
|
||||
(true, true, true),
|
||||
),
|
||||
(
|
||||
"[features]\ntranscript_v2 = true\n",
|
||||
vec!["codex", "--no-alt-screen"],
|
||||
(false, false, true),
|
||||
),
|
||||
(
|
||||
"[features]\ntranscript_v2 = false\n",
|
||||
vec!["codex"],
|
||||
(true, false, true),
|
||||
),
|
||||
(
|
||||
"[tui]\nalternate_screen = \"never\"\nstatus_line = []\n",
|
||||
vec!["codex"],
|
||||
(false, false, false),
|
||||
),
|
||||
] {
|
||||
std::fs::write(home.path().join("config.toml"), config)?;
|
||||
let cli = Cli::try_parse_from(args)?;
|
||||
let presentation = load(
|
||||
&cli,
|
||||
home.path(),
|
||||
LoaderOverrides {
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
},
|
||||
Vec::new(),
|
||||
/*config_cwd*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(
|
||||
presentation.screen.use_alt_screen,
|
||||
presentation.screen.transcript_mode.is_owned(),
|
||||
presentation.screen.status_line_enabled
|
||||
),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Retain one startup draft in memory until the intended session accepts the handoff.
|
||||
//!
|
||||
//! Recovery belongs to the startup task, never a process-global file, log, or session. After a
|
||||
//! startup error the caller restores the terminal before showing the unsent text. Printing takes
|
||||
//! the snapshot, so nested fatal/error paths cannot print it twice.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::future::Future;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::ComposerDraftSnapshot;
|
||||
use crate::bottom_pane::InputResult;
|
||||
|
||||
tokio::task_local! {
|
||||
static DRAFT: RefCell<Option<RecoveryDraft>>;
|
||||
}
|
||||
|
||||
struct RecoveryDraft {
|
||||
snapshot: ComposerDraftSnapshot,
|
||||
stage: RecoveryStage,
|
||||
}
|
||||
|
||||
enum RecoveryStage {
|
||||
Startup,
|
||||
HandedOff,
|
||||
Submitted,
|
||||
Dispatched(String),
|
||||
}
|
||||
|
||||
/// Keep draft recovery isolated to this startup, including futures polled during initialization.
|
||||
pub(crate) async fn scope<F: Future>(future: F) -> F::Output {
|
||||
DRAFT.scope(RefCell::new(/*value*/ None), future).await
|
||||
}
|
||||
|
||||
/// Replace the single recovery copy after input changes or before handing the draft off.
|
||||
pub(crate) fn remember(snapshot: ComposerDraftSnapshot) {
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
*draft.borrow_mut() = Some(RecoveryDraft {
|
||||
snapshot,
|
||||
stage: RecoveryStage::Startup,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Track the merged destination only once startup has transferred its draft into ChatWidget.
|
||||
pub(crate) fn handed_off(snapshot: impl FnOnce() -> ComposerDraftSnapshot) {
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
if let Some(draft) = draft.borrow_mut().as_mut() {
|
||||
draft.snapshot = snapshot();
|
||||
draft.stage = RecoveryStage::HandedOff;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh pending recovery after local edits without retaining any post-startup input.
|
||||
pub(crate) fn refresh_after_handoff(snapshot: impl FnOnce() -> ComposerDraftSnapshot) {
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
if let Some(draft) = draft.borrow_mut().as_mut()
|
||||
&& matches!(draft.stage, RecoveryStage::HandedOff)
|
||||
{
|
||||
draft.snapshot = snapshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Retain actual submission output, including burst text flushed while consuming the composer.
|
||||
pub(crate) fn submitted(result: &InputResult) {
|
||||
let submission = match result {
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => Some((text, text_elements, &[][..])),
|
||||
InputResult::Queued {
|
||||
text,
|
||||
text_elements,
|
||||
pending_pastes,
|
||||
..
|
||||
} => Some((text, text_elements, pending_pastes.as_slice())),
|
||||
InputResult::CommandWithArgs(crate::slash_command::SlashCommand::Plan, ..) => None,
|
||||
InputResult::None
|
||||
| InputResult::ParentOwnedInputBlocked
|
||||
| InputResult::Command(_)
|
||||
| InputResult::ServiceTierCommand(_)
|
||||
| InputResult::CommandWithArgs(..) => return,
|
||||
};
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
if let Some(draft) = draft.borrow_mut().as_mut()
|
||||
&& matches!(draft.stage, RecoveryStage::HandedOff)
|
||||
{
|
||||
if let Some((text, text_elements, pending_pastes)) = submission {
|
||||
draft.snapshot.text.clone_from(text);
|
||||
draft.snapshot.text_elements.clone_from(text_elements);
|
||||
draft.snapshot.pending_pastes = pending_pastes.to_vec();
|
||||
draft.snapshot.cursor = text.len();
|
||||
}
|
||||
// Later editor input is a different draft; preserve this one until acknowledgment.
|
||||
draft.stage = RecoveryStage::Submitted;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Associate the recovered submission with its actual queued request, not an earlier CLI prompt.
|
||||
pub(crate) fn bind_submission(text: &str, client_id: &str) {
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
if let Some(draft) = draft.borrow_mut().as_mut()
|
||||
&& matches!(draft.stage, RecoveryStage::Submitted)
|
||||
{
|
||||
let expanded = ChatComposer::expand_pending_pastes(
|
||||
&draft.snapshot.text,
|
||||
draft.snapshot.text_elements.clone(),
|
||||
&draft.snapshot.pending_pastes,
|
||||
)
|
||||
.0;
|
||||
// /plan submits its arguments while recovery retains the original command.
|
||||
if expanded == text
|
||||
|| crate::bottom_pane::prompt_args::parse_slash_name(&expanded)
|
||||
.is_some_and(|(name, args, _)| name == "plan" && args.trim() == text)
|
||||
{
|
||||
draft.stage = RecoveryStage::Dispatched(client_id.to_owned());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Only acceptance of the recovered message releases its unsent copy.
|
||||
pub(crate) fn acknowledged(client_id: &str) {
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
let mut draft = draft.borrow_mut();
|
||||
if draft.as_ref().is_some_and(
|
||||
|draft| matches!(&draft.stage, RecoveryStage::Dispatched(id) if id == client_id),
|
||||
) {
|
||||
draft.take();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget recovery once startup safely transfers the draft or the user cancels the session.
|
||||
pub(crate) fn clear() {
|
||||
let _ = DRAFT.try_with(|draft| {
|
||||
draft.borrow_mut().take();
|
||||
});
|
||||
}
|
||||
|
||||
fn take_unsent_text() -> Option<String> {
|
||||
let snapshot = DRAFT
|
||||
.try_with(|draft| draft.borrow_mut().take())
|
||||
.ok()??
|
||||
.snapshot;
|
||||
let (text, _) = ChatComposer::expand_pending_pastes(
|
||||
&snapshot.text,
|
||||
snapshot.text_elements,
|
||||
&snapshot.pending_pastes,
|
||||
);
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Keep the typed content recoverable without letting pasted control bytes change the terminal.
|
||||
let mut visible = String::with_capacity(text.len());
|
||||
for character in text.chars() {
|
||||
if character.is_control() && !matches!(character, '\n' | '\t') {
|
||||
visible.extend(character.escape_default());
|
||||
} else {
|
||||
visible.push(character);
|
||||
}
|
||||
}
|
||||
Some(visible)
|
||||
}
|
||||
|
||||
/// Display unsent text only after the caller has restored normal terminal modes.
|
||||
pub(crate) fn print_unsent_draft() {
|
||||
if let Some(text) = take_unsent_text() {
|
||||
let _ = writeln!(std::io::stderr().lock(), "\nUnsent draft:\n{text}\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_recovery_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Verify unsent startup text survives edits, buffered pastes, and handoff.
|
||||
|
||||
use super::*;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn snapshot(text: &str) -> ComposerDraftSnapshot {
|
||||
ComposerDraftSnapshot {
|
||||
text: text.to_string(),
|
||||
cursor: text.len(),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
pending_pastes: Vec::new(),
|
||||
startup_local_history: Vec::new(),
|
||||
last_composer_activity_at: None,
|
||||
sparkle_draft: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_keeps_edits_and_pastes_after_chatwidget_handoff() {
|
||||
for destination in ["", "existing draft\n"] {
|
||||
scope(async {
|
||||
let (mut chat, _sender, _rx, _op_rx) =
|
||||
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
|
||||
let draft = snapshot("startup draft");
|
||||
remember(draft.clone());
|
||||
chat.handle_paste(destination.trim_end().to_string());
|
||||
let mut pending = Some(draft);
|
||||
let mut confirmed = true;
|
||||
chat.restore_startup_input_when_ready(&mut pending, &mut confirmed);
|
||||
assert!(pending.is_none());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
let pasted = format!(" edited {}", "x".repeat(/*n*/ 1100));
|
||||
chat.handle_paste(pasted.clone());
|
||||
// This is the exact expanded text printed if startup fails before session readiness.
|
||||
assert_eq!(
|
||||
take_unsent_text(),
|
||||
Some(format!("{destination}startup draf{pasted}")),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_refresh_stops_after_submission_or_startup_completion() {
|
||||
scope(async {
|
||||
remember(snapshot("before handoff"));
|
||||
refresh_after_handoff(|| panic!("protected input must not replace the startup draft"));
|
||||
handed_off(|| snapshot("confirmed submission"));
|
||||
bind_submission("confirmed submission", "initial-prompt");
|
||||
acknowledged("initial-prompt");
|
||||
submitted(&InputResult::Submitted {
|
||||
text: "confirmed submission".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
bind_submission("other message", "unrelated");
|
||||
bind_submission("confirmed submission", "recovered");
|
||||
bind_submission("confirmed submission", "later-identical-message");
|
||||
acknowledged("initial-prompt");
|
||||
acknowledged("later-identical-message");
|
||||
refresh_after_handoff(|| panic!("new edits must not replace a submitted draft"));
|
||||
assert_eq!(take_unsent_text(), Some("confirmed submission".to_string()));
|
||||
|
||||
remember(snapshot("editable draft"));
|
||||
handed_off(|| snapshot("merged draft"));
|
||||
submitted(&InputResult::Submitted {
|
||||
text: "merged draft".into(),
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
bind_submission("merged draft", "accepted");
|
||||
acknowledged("accepted");
|
||||
assert_eq!(take_unsent_text(), None);
|
||||
for submission in [
|
||||
InputResult::CommandWithArgs(
|
||||
crate::slash_command::SlashCommand::Plan,
|
||||
"make a plan".into(),
|
||||
Vec::new(),
|
||||
),
|
||||
InputResult::Queued {
|
||||
text: "/plan make a plan".into(),
|
||||
text_elements: Vec::new(),
|
||||
action: crate::bottom_pane::QueuedInputAction::ParseSlash,
|
||||
pending_pastes: Vec::new(),
|
||||
},
|
||||
] {
|
||||
remember(snapshot("/plan make a plan"));
|
||||
handed_off(|| snapshot("/plan make a plan"));
|
||||
submitted(&submission);
|
||||
bind_submission("make a plan", "plan-request");
|
||||
acknowledged("plan-request");
|
||||
assert_eq!(take_unsent_text(), None);
|
||||
}
|
||||
handed_off(|| panic!("completed startup must not retain input"));
|
||||
refresh_after_handoff(|| panic!("completed startup must not copy edits"));
|
||||
assert_eq!(take_unsent_text(), None);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_keeps_buffered_input_on_exit_or_queued_submission() {
|
||||
for queue in [false, true] {
|
||||
scope(async {
|
||||
let (mut chat, _sender, _rx, _op_rx) =
|
||||
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
|
||||
let draft = snapshot("startup draft");
|
||||
remember(draft.clone());
|
||||
let mut pending = Some(draft);
|
||||
let mut confirmed = false;
|
||||
chat.restore_startup_input_when_ready(&mut pending, &mut confirmed);
|
||||
chat.set_queue_submissions_until_session_configured(queue);
|
||||
// Rapid raw keys remain buffered outside the textarea until the burst ends.
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char('y')));
|
||||
assert_eq!(chat.composer_text_with_pending(), "startup draft");
|
||||
if !queue {
|
||||
// A fatal exit must include keys still waiting for the paste timer.
|
||||
assert_eq!(take_unsent_text(), Some("startup draftxy".to_string()));
|
||||
return;
|
||||
}
|
||||
// Queueing must capture the buffer without waiting for a draw/tick flush.
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["startup draftxy".to_string()]
|
||||
);
|
||||
assert_eq!(take_unsent_text(), Some("startup draftxy".to_string()));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_uses_pending_pastes_from_the_actual_queued_result() {
|
||||
scope(async {
|
||||
remember(snapshot("before burst"));
|
||||
handed_off(|| snapshot("before burst"));
|
||||
let placeholder = "[Pasted text]";
|
||||
submitted(&InputResult::Queued {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
action: crate::bottom_pane::QueuedInputAction::Literal,
|
||||
pending_pastes: vec![(
|
||||
placeholder.to_string(),
|
||||
"complete queued paste\n\twith control\u{1b}[31m".to_string(),
|
||||
)],
|
||||
});
|
||||
assert_eq!(
|
||||
take_unsent_text(),
|
||||
Some("complete queued paste\n\twith control\\u{1b}[31m".to_string())
|
||||
);
|
||||
assert_eq!(take_unsent_text(), None);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -200,9 +200,20 @@ async fn startup_typeahead_pty_child() {
|
||||
return;
|
||||
};
|
||||
let signals = PathBuf::from(signals);
|
||||
let initialized_terminal = crate::tui::init().expect("initialize startup terminal");
|
||||
let terminal_restore_guard = crate::TerminalRestoreGuard::new();
|
||||
let mut startup_draft = crate::startup_draft::StartupDraft::new(
|
||||
initialized_terminal,
|
||||
terminal_restore_guard,
|
||||
crate::startup_draft::StartupDraftInitialScreen::Composer,
|
||||
crate::startup_draft::StartupDraftSessionAction::New,
|
||||
crate::startup_draft::StartupScreen {
|
||||
use_alt_screen: false,
|
||||
transcript_mode: crate::transcript_mode::TranscriptMode::Terminal,
|
||||
status_line_enabled: true,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
disable_paste_burst: false,
|
||||
},
|
||||
)
|
||||
.expect("initialize the real startup composer");
|
||||
|
||||
|
||||
@@ -20,16 +20,22 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
// macOS's default temporary directory leaves too little room for the control socket path.
|
||||
let codex_home = tempfile::tempdir_in("/tmp")?;
|
||||
write_test_config(codex_home.path(), &repo_root)?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
std::fs::write(
|
||||
config_path,
|
||||
format!("{config}\n[tui]\nstatus_line = [\"thread-id\"]\n"),
|
||||
)?;
|
||||
let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?;
|
||||
std::fs::create_dir_all(socket.parent().unwrap())?;
|
||||
let listener = UnixListener::bind(socket.as_path())?;
|
||||
let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel();
|
||||
let (restore_tx, restore_rx) = tokio::sync::oneshot::channel();
|
||||
let server_cwd = repo_root.clone();
|
||||
let id = "00000000-0000-0000-0000-000000000001";
|
||||
let server = tokio::spawn(async move {
|
||||
let mut methods = Vec::new();
|
||||
let mut restore_rx = Some(restore_rx);
|
||||
let id = "00000000-0000-0000-0000-000000000001";
|
||||
let thread = json!({
|
||||
"id": id, "sessionId": id, "preview": "", "ephemeral": false,
|
||||
"modelProvider": "openai", "createdAt": 1, "updatedAt": 2,
|
||||
@@ -91,7 +97,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
}
|
||||
"model/list" => json!({"data": [], "nextCursor": null}),
|
||||
"config/read" => {
|
||||
json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai", "projects": {
|
||||
json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai",
|
||||
"tui": {"status_line": ["thread-id"]}, "projects": {
|
||||
server_cwd.to_string_lossy(): {"trust_level": "trusted"}
|
||||
}}, "origins": {}, "layers": []})
|
||||
}
|
||||
@@ -149,7 +156,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
let mut disconnect_tx = Some(disconnect_tx);
|
||||
let mut restore_tx = Some(restore_tx);
|
||||
for expected in [
|
||||
"gpt-5.6-terra",
|
||||
id, // The model label does not establish that the client has attached a thread.
|
||||
"preserved-draft",
|
||||
"Reconnecting",
|
||||
"preserved-draft!",
|
||||
@@ -165,7 +172,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
terminal.screen_contents()
|
||||
);
|
||||
match expected {
|
||||
"gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?,
|
||||
ready if ready == id => terminal.write_input(b"preserved-draft")?,
|
||||
"preserved-draft" => {
|
||||
disconnect_tx.take().unwrap().send(()).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user