Keep the composer editable during TUI startup (#38642)

## Why

Configuration and app-server initialization can take time before the main TUI is ready, leaving users unable to begin drafting a prompt.

## What changed

- Show a provisional composer while startup work runs and carry its text, cursor position, paste state, and attachments into the initialized chat.
- Limit the provisional composer to safe editing and cancellation, and quarantine input around session pickers, approvals, and other actionable startup screens.
- Preserve configuration validation before terminal checks and recover terminal state cleanly from startup failures and caught panics.

## Testing

- Cover startup editing, multiline and large pastes, keymaps, cancellation, session-picker handoff, approval boundaries, and draft restoration.
- Verify non-interactive launches report configuration errors before terminal errors.

GitOrigin-RevId: fe04a85cfbbcff21ff87fa81fd17474827858575
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-08-14 20:30:30 +00:00
1 parent 58d2daba45
commit a0bed4be21
26 files changed
+4348 -1151

No files matched your search

+76
View File
@@ -38,6 +38,82 @@ fn strict_config_rejects_unknown_config_override() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn interactive_validates_config_before_requiring_terminal() -> Result<()> {
let cases: &[(&[&str], &str, &str, &str)] = &[
(&[], "config.toml", "model = [", "Error loading config.toml"),
(
&["--strict-config"],
"config.toml",
"unknown_key = true",
"unknown configuration field",
),
(
&["--strict-config", "-c", "foo=bar"],
"config.toml",
"",
"unknown configuration field",
),
(
&["--profile", "work"],
"work.config.toml",
"model = [",
"work.config.toml",
),
(
&["-c", "model_provider=\"missing\""],
"config.toml",
"",
"Model provider `missing` not found",
),
(&[], "config.toml", "", "stdin is not a terminal"),
];
for &(args, config_file, contents, expected_error) in cases {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join(config_file), contents)?;
let mut cmd = codex_command(codex_home.path())?;
cmd.env("TERM", "xterm-256color")
.current_dir(codex_home.path())
.args(args)
.assert()
.failure()
.stderr(contains(expected_error));
}
Ok(())
}
#[test]
fn interactive_remote_default_preserves_remote_working_directory_before_requiring_terminal()
-> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join("config.toml"), "")?;
std::fs::write(
codex_home.path().join("environments.toml"),
r#"default = "remote"
include_local = false
[[environments]]
id = "remote"
url = "ws://127.0.0.1:4512"
"#,
)?;
let remote_only_cwd = codex_home.path().join("remote-only-working-directory");
let mut cmd = codex_command(codex_home.path())?;
cmd.env("TERM", "xterm-256color")
.current_dir(codex_home.path())
.arg("--cd")
.arg(remote_only_cwd)
.assert()
.failure()
.stderr(contains("stdin is not a terminal"));
Ok(())
}
#[test] #[test]
fn strict_config_is_not_supported_for_cloud_command() -> Result<()> { fn strict_config_is_not_supported_for_cloud_command() -> Result<()> {
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
+14 -574
View File
@@ -73,6 +73,7 @@ use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection; use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget; use crate::resume_picker::SessionTarget;
use crate::session_state::ThreadSessionState; use crate::session_state::ThreadSessionState;
use crate::startup_draft::StartupDraftPump;
#[cfg(test)] #[cfg(test)]
use crate::test_support::PathBufExt; use crate::test_support::PathBufExt;
#[cfg(test)] #[cfg(test)]
@@ -225,6 +226,7 @@ mod resize_reflow;
mod safety_buffering; mod safety_buffering;
mod session_lifecycle; mod session_lifecycle;
mod side; mod side;
mod startup;
mod startup_prompts; mod startup_prompts;
mod thread_events; mod thread_events;
mod thread_goal_actions; mod thread_goal_actions;
@@ -595,6 +597,10 @@ pub(crate) struct App {
pending_primary_events: VecDeque<ThreadBufferedEvent>, pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests, pending_app_server_requests: PendingAppServerRequests,
pending_startup_thread_start: bool, pending_startup_thread_start: bool,
/// Keeps protected screens quarantined until initialized chat receives genuine user input.
startup_protected_input_boundary: bool,
/// Keeps that boundary armed while a startup approval waits for the typing-idle timer.
startup_pending_protected_request: bool,
/// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives. /// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives.
rate_limit_hard_stop_generation: u64, rate_limit_hard_stop_generation: u64,
// Serialize plugin enablement writes per plugin so stale completions cannot // Serialize plugin enablement writes per plugin so stale completions cannot
@@ -635,38 +641,6 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<App
.then_some(turn_error) .then_some(turn_error)
} }
async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option<String> {
let provider = create_model_provider(provider.clone(), /*auth_manager*/ None);
match provider.runtime_base_url().await {
Ok(base_url) => base_url,
Err(err) => {
tracing::warn!(%err, "failed to resolve runtime model provider base URL for status");
None
}
}
}
fn spawn_startup_thread_start(
app_server: &AppServerSession,
config: Config,
app_event_tx: AppEventSender,
) {
let request_handle = app_server.request_handle();
let thread_params_mode = app_server.thread_params_mode();
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
tokio::spawn(async move {
let result = crate::app_server_session::start_thread_with_request_handle(
request_handle,
config,
thread_params_mode,
remote_cwd_override,
)
.await
.map_err(|err| format!("{err:#}"));
app_event_tx.send(AppEvent::StartupThreadStarted { result });
});
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum ActiveTurnSteerRace { enum ActiveTurnSteerRace {
Missing, Missing,
@@ -775,548 +749,6 @@ impl App {
} }
} }
#[allow(clippy::too_many_arguments)]
pub async fn run(
tui: &mut tui::Tui,
mut app_server: AppServerSession,
mut config: Config,
launch_cwd: PathBuf,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_config_bundle: CloudConfigBundleLoader,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
app_server_target: AppServerTarget,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
startup_elapsed_before_app: Duration,
startup_bootstrap: Option<AppServerBootstrap>,
startup_hooks_browser: Option<HooksListEntry>,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
let startup_started_at = Instant::now();
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
emit_system_bwrap_warning(&app_event_tx, &config);
tui.set_notification_settings(
config.tui_notifications.method,
config.tui_notifications.condition,
);
let harness_overrides =
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
let bootstrap = match startup_bootstrap {
Some(bootstrap) => bootstrap,
None => app_server.bootstrap(&config).await?,
};
let bootstrap_ms = bootstrap.duration.as_millis();
if matches!(
&session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
) {
apply_managed_new_thread_defaults(
&mut config,
app_server.managed_new_thread_defaults(),
&cli_kv_overrides,
&harness_overrides,
);
}
let mut model = config.model.clone().unwrap_or(bootstrap.default_model);
let available_models = bootstrap.available_models;
let remote_connection = crate::status::remote_connection::remote_connection_status_value(
&app_server_target,
app_server.server_version(),
);
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
&available_models,
)
.await?;
if let Some(exit_info) = exit_info {
app_server
.shutdown()
.await
.inspect_err(|err| {
tracing::warn!("app-server shutdown failed: {err}");
})
.ok();
return Ok(exit_info);
}
if let Some(updated_model) = config.model.clone() {
model = updated_model;
}
let model_catalog = Arc::new(ModelCatalog::new(available_models.clone()));
let feedback_audience = bootstrap.feedback_audience;
let auth_mode = bootstrap.auth_mode;
let has_chatgpt_account = bootstrap.has_chatgpt_account;
let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt));
let requires_openai_auth = bootstrap.requires_openai_auth;
let status_account_display = bootstrap.status_account_display.clone();
let initial_plan_type = bootstrap.plan_type;
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
model.as_str(),
model.as_str(),
/*account_id*/ None,
bootstrap.account_email.clone(),
auth_mode,
codex_login::default_client::originator().value,
config.otel.log_user_prompt,
user_agent(),
serde_json::from_value(serde_json::json!("cli"))
.unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")),
);
if config
.tui_status_line
.as_ref()
.is_some_and(|cmd| !cmd.is_empty())
{
session_telemetry.counter("codex.status_line", /*inc*/ 1, &[]);
}
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
let workspace_command_runner: WorkspaceCommandRunner = Arc::new(
AppServerWorkspaceCommandRunner::new(app_server.request_handle()),
);
let runtime_model_provider_started_at = Instant::now();
let runtime_model_provider_base_url =
resolve_runtime_model_provider_base_url(&config.model_provider).await;
let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis();
let enhanced_keys_supported = tui.enhanced_keys_supported();
let wait_for_initial_session_configured =
Self::should_wait_for_initial_session(&session_selection);
let should_prompt_for_paused_goal_after_startup_resume =
Self::should_prompt_for_paused_goal_after_startup_resume(
&session_selection,
&initial_prompt,
&initial_images,
);
let thread_and_widget_started_at = Instant::now();
let pending_startup_thread_start = matches!(
&session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
);
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone());
// Count a startup tooltip once the initial chat widget can render it.
let startup_tooltip_override =
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run)
.await;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
runtime_model_provider_base_url: runtime_model_provider_base_url.clone(),
initial_plan_type,
model: Some(model.clone()),
startup_tooltip_override,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
let mut chat_widget = ChatWidget::new_with_app_event(init);
chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true);
(chat_widget, None)
}
SessionSelection::Resume(target_session) => {
if let Some(history_mode) = target_session.history_mode {
app_server.remember_thread_history_mode(target_session.thread_id, history_mode);
}
let model_settings = config_persistence::resume_model_settings_for_overrides(
&config,
&harness_overrides,
);
let resumed = app_server
.resume_thread(config.clone(), target_session.thread_id, model_settings)
.await
.map_err(|err| session_start_error("resume", &target_session, err))?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
runtime_model_provider_base_url: runtime_model_provider_base_url.clone(),
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(resumed))
}
SessionSelection::Fork(target_session) => {
session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "cli_subcommand")],
);
let forked = app_server
.fork_thread(config.clone(), target_session.thread_id)
.await
.map_err(|err| session_start_error("fork", &target_session, err))?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
runtime_model_provider_base_url: runtime_model_provider_base_url.clone(),
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(forked))
}
};
chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width);
chat_widget.remote_connection = remote_connection;
let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis();
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
Fix the config and retry.\n\
See the Codex keymap documentation for supported actions and examples."
)
})?;
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let mut app = Self {
model_catalog,
session_telemetry: session_telemetry.clone(),
app_event_tx,
chat_widget,
workspace_command_runner: Some(workspace_command_runner),
config,
launch_cwd,
state_db,
cli_kv_overrides,
harness_overrides,
loader_overrides,
cloud_config_bundle,
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
enhanced_keys_supported,
keymap: runtime_keymap,
key_chord_matcher: KeyChordMatcher::default(),
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
skill_load_warnings: SkillLoadWarningState::default(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: feedback.clone(),
feedback_audience,
environment_manager,
app_server_target,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start,
rate_limit_hard_stop_generation: 0,
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
};
if let Some(entry) = startup_hooks_browser {
app.chat_widget.open_hooks_browser(entry);
}
app.update_visible_history_rows(tui.terminal.last_known_screen_size);
let initial_session_started_at = Instant::now();
if let Some(started) = initial_started_thread {
let thread_id = started.session.thread_id;
if started.blocks_direct_input {
app.mark_primary_thread_parent_owned(thread_id);
}
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
if should_prompt_for_paused_goal_after_startup_resume {
app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id)
.await;
}
}
let initial_session_ms = initial_session_started_at.elapsed().as_millis();
// On startup, if a managed filesystem sandbox is active, warn about
// world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
let startup_permission_profile = app.config.permissions.effective_permission_profile();
let should_check = crate::windows_sandbox::level_from_config(&app.config)
!= WindowsSandboxLevel::Disabled
&& managed_filesystem_sandbox_is_restricted(&startup_permission_profile)
&& !app
.config
.notices
.hide_world_writable_warning
.unwrap_or(false);
if should_check {
let cwd = app.config.cwd.clone();
let workspace_roots = app.config.effective_workspace_roots();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let tx = app.app_event_tx.clone();
let logs_base_dir = app.config.codex_home.clone();
Self::spawn_world_writable_scan(
cwd,
workspace_roots,
env_map,
logs_base_dir,
startup_permission_profile,
tx,
);
}
}
let event_stream_started_at = Instant::now();
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
tui.schedule_screen_size_recheck(Duration::ZERO);
tracing::info!(
duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(),
bootstrap_ms = %bootstrap_ms,
runtime_model_provider_ms = %runtime_model_provider_ms,
thread_and_widget_ms = %thread_and_widget_ms,
initial_session_ms = %initial_session_ms,
event_stream_ms = %event_stream_started_at.elapsed().as_millis(),
"tui startup initial frame scheduled"
);
app.refresh_startup_skills(&app_server);
// Kick off a non-blocking rate-limit prefetch so the first `/status`
// already has data and available reset credits can be surfaced, without
// delaying the initial frame render.
if requires_openai_auth && has_chatgpt_account {
let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check();
app.refresh_rate_limits(
&app_server,
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
},
);
}
let mut listen_for_app_server_events = true;
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
let mut waiting_for_initial_session_header = true;
#[cfg(not(debug_assertions))]
let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version {
let control = Box::pin(app.handle_event(
tui,
&mut app_server,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
))),
))
.await?;
match control {
AppRunControl::Continue => None,
AppRunControl::Exit(exit_reason) => Some(exit_reason),
}
} else {
None
};
#[cfg(debug_assertions)]
let pre_loop_exit_reason: Option<ExitReason> = None;
let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason {
Ok(exit_reason)
} else {
loop {
let initial_session_header_pending = waiting_for_initial_session_header
&& app.primary_session_configured.is_some()
&& !app_event_rx.is_empty();
let control = select! {
Some(event) = app_event_rx.recv() => {
let is_initial_session_header = matches!(
&event,
AppEvent::InsertHistoryCell(cell)
if cell.as_any().is::<history_cell::SessionInfoCell>()
);
match Box::pin(app.handle_event(tui, &mut app_server, event)).await {
Ok(control) => {
if is_initial_session_header {
waiting_for_initial_session_header = false;
}
control
}
Err(err) => break Err(err),
}
}
active = async {
if let Some(rx) = app.active_thread_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if App::should_handle_active_thread_events(
waiting_for_initial_session_configured,
app.active_thread_rx.is_some()
) => {
if let Some(event) = active {
if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await {
break Err(err);
}
} else {
app.clear_active_thread().await;
}
AppRunControl::Continue
}
event = tui_events.next(), if !initial_session_header_pending => {
if let Some(event) = event {
match app.handle_tui_event(tui, &mut app_server, event).await {
Ok(control) => control,
Err(err) => break Err(err),
}
} else {
tracing::warn!("terminal input stream closed; shutting down active thread");
app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await
}
}
app_server_event = app_server.next_event(), if listen_for_app_server_events => {
match app_server_event {
Some(event) => app.handle_app_server_event(&app_server, event).await,
None => {
listen_for_app_server_events = false;
tracing::warn!("app-server event stream closed");
}
}
AppRunControl::Continue
}
};
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
) {
waiting_for_initial_session_configured = false;
}
match control {
AppRunControl::Continue => {}
AppRunControl::Exit(reason) => break Ok(reason),
}
}
};
if let Err(err) = app_server.shutdown().await {
tracing::warn!(error = %err, "failed to shut down embedded app server");
}
let clear_pet_result = tui.clear_ambient_pet_image();
let clear_result = tui.terminal.clear();
let exit_reason = match exit_reason_result {
Ok(exit_reason) => {
clear_pet_result?;
clear_result?;
exit_reason
}
Err(err) => {
if let Err(clear_pet_err) = clear_pet_result {
tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image");
}
if let Err(clear_err) = clear_result {
tracing::warn!(error = %clear_err, "failed to clear terminal UI");
}
return Err(err);
}
};
let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id);
let resume_hint = resume_hint_for_resumable_thread(
thread_id,
app.chat_widget.thread_name(),
app.chat_widget.rollout_path().as_deref(),
);
Ok(AppExitInfo {
token_usage: app.token_usage(),
thread_id,
resume_hint,
update_action: app.pending_update_action,
exit_reason,
})
}
pub(crate) async fn handle_tui_event( pub(crate) async fn handle_tui_event(
&mut self, &mut self,
tui: &mut tui::Tui, tui: &mut tui::Tui,
@@ -1369,8 +801,16 @@ See the Codex keymap documentation for supported actions and examples."
return Ok(AppRunControl::Continue); return Ok(AppRunControl::Continue);
} }
// Allow widgets to process any pending timers before rendering. // Allow widgets to process any pending timers before rendering.
let had_active_view = self.chat_widget.has_active_view();
self.chat_widget.pre_draw_tick(); self.chat_widget.pre_draw_tick();
let rendered_area = self.render_chat_widget_frame(tui, screen_size)?; let rendered_area = self.render_chat_widget_frame(tui, screen_size)?;
if !had_active_view
&& self.chat_widget.has_active_view()
&& self.startup_protected_input_boundary
{
tui.discard_pending_input_before_interactive_screen()?;
self.startup_pending_protected_request = false;
}
if self.chat_widget.ambient_pet_image_enabled() { if self.chat_widget.ambient_pet_image_enabled() {
let ambient_pet_area = Rect::new( let ambient_pet_area = Rect::new(
/*x*/ 0, /*x*/ 0,
@@ -87,6 +87,10 @@ impl App {
.resolve_notification(&notification.request_id) .resolve_notification(&notification.request_id)
{ {
self.chat_widget.dismiss_app_server_request(&request); self.chat_widget.dismiss_app_server_request(&request);
if self.startup_pending_protected_request {
self.startup_pending_protected_request =
self.chat_widget.has_pending_protected_request();
}
} }
} }
ServerNotification::McpServerStatusUpdated(_) => { ServerNotification::McpServerStatusUpdated(_) => {
+757
View File
@@ -0,0 +1,757 @@
//! Startup bootstrap, draft handoff, and protected-input orchestration for the TUI app.
//!
//! 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.
use super::*;
async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option<String> {
let provider = create_model_provider(provider.clone(), /*auth_manager*/ None);
match provider.runtime_base_url().await {
Ok(base_url) => base_url,
Err(err) => {
tracing::warn!(%err, "failed to resolve runtime model provider base URL for status");
None
}
}
}
fn spawn_startup_thread_start(
app_server: &AppServerSession,
config: Config,
app_event_tx: AppEventSender,
) {
let request_handle = app_server.request_handle();
let thread_params_mode = app_server.thread_params_mode();
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
tokio::spawn(async move {
let result = crate::app_server_session::start_thread_with_request_handle(
request_handle,
config,
thread_params_mode,
remote_cwd_override,
)
.await
.map_err(|err| format!("{err:#}"));
app_event_tx.send(AppEvent::StartupThreadStarted { result });
});
}
impl App {
/// Recognizes queued requests before they become visible protected screens.
pub(super) fn has_queued_startup_protected_request(&self) -> bool {
self.startup_protected_input_boundary
&& (self
.active_thread_rx
.as_ref()
.is_some_and(|receiver| !receiver.is_empty())
|| self
.pending_primary_events
.iter()
.any(|event| matches!(event, ThreadBufferedEvent::Request(_))))
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
tui: &mut tui::Tui,
mut app_server: AppServerSession,
mut config: Config,
launch_cwd: PathBuf,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_config_bundle: CloudConfigBundleLoader,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
app_server_target: AppServerTarget,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
startup_elapsed_before_app: Duration,
startup_bootstrap: Option<AppServerBootstrap>,
startup_hooks_browser: Option<HooksListEntry>,
mut startup_draft: StartupDraftPump,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
async fn shutdown_on_startup_error(
app_server: AppServerSession,
error: impl Into<color_eyre::eyre::Report>,
) -> Result<AppExitInfo> {
if let Err(shutdown_error) = app_server.shutdown().await {
tracing::warn!("app-server shutdown failed: {shutdown_error}");
}
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(())
}
let startup_started_at = Instant::now();
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
emit_system_bwrap_warning(&app_event_tx, &config);
tui.set_notification_settings(
config.tui_notifications.method,
config.tui_notifications.condition,
);
let harness_overrides =
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
let bootstrap = match startup_bootstrap {
Some(bootstrap) => bootstrap,
None => match startup_draft
.run_until(tui, app_server.bootstrap(&config))
.await
{
Ok(bootstrap) => bootstrap?,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
},
};
let bootstrap_ms = bootstrap.duration.as_millis();
if matches!(
&session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
) {
apply_managed_new_thread_defaults(
&mut config,
app_server.managed_new_thread_defaults(),
&cli_kv_overrides,
&harness_overrides,
);
}
let mut model = config.model.clone().unwrap_or(bootstrap.default_model);
let available_models = bootstrap.available_models;
let remote_connection = crate::status::remote_connection::remote_connection_status_value(
&app_server_target,
app_server.server_version(),
);
if let Err(err) = startup_draft.flush_pending_events(tui).await {
return shutdown_on_startup_error(app_server, err).await;
}
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
&available_models,
)
.await?;
if let Some(exit_info) = exit_info {
app_server
.shutdown()
.await
.inspect_err(|err| {
tracing::warn!("app-server shutdown failed: {err}");
})
.ok();
return Ok(exit_info);
}
if let Some(updated_model) = config.model.clone() {
model = updated_model;
}
let model_catalog = Arc::new(ModelCatalog::new(available_models.clone()));
let feedback_audience = bootstrap.feedback_audience;
let auth_mode = bootstrap.auth_mode;
let has_chatgpt_account = bootstrap.has_chatgpt_account;
let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt));
let requires_openai_auth = bootstrap.requires_openai_auth;
let status_account_display = bootstrap.status_account_display.clone();
let initial_plan_type = bootstrap.plan_type;
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
model.as_str(),
model.as_str(),
/*account_id*/ None,
bootstrap.account_email.clone(),
auth_mode,
codex_login::default_client::originator().value,
config.otel.log_user_prompt,
user_agent(),
serde_json::from_value(serde_json::json!("cli"))
.unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")),
);
if config
.tui_status_line
.as_ref()
.is_some_and(|cmd| !cmd.is_empty())
{
session_telemetry.counter("codex.status_line", /*inc*/ 1, &[]);
}
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
let workspace_command_runner: WorkspaceCommandRunner = Arc::new(
AppServerWorkspaceCommandRunner::new(app_server.request_handle()),
);
let runtime_model_provider_started_at = Instant::now();
let runtime_model_provider_base_url = match startup_draft
.run_until(
tui,
resolve_runtime_model_provider_base_url(&config.model_provider),
)
.await
{
Ok(base_url) => base_url,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
};
let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis();
let enhanced_keys_supported = tui.enhanced_keys_supported();
let wait_for_initial_session_configured =
Self::should_wait_for_initial_session(&session_selection);
let should_prompt_for_paused_goal_after_startup_resume =
Self::should_prompt_for_paused_goal_after_startup_resume(
&session_selection,
&initial_prompt,
&initial_images,
);
let thread_and_widget_started_at = Instant::now();
let pending_startup_thread_start = matches!(
&session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
);
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone());
// Count a startup tooltip once the initial chat widget can render it.
let startup_tooltip_override = match startup_draft
.run_until(
tui,
prepare_startup_tooltip_override(
&mut config,
&available_models,
is_first_run,
),
)
.await
{
Ok(tooltip_override) => tooltip_override,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
};
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
runtime_model_provider_base_url: runtime_model_provider_base_url.clone(),
initial_plan_type,
model: Some(model.clone()),
startup_tooltip_override,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
let mut chat_widget = ChatWidget::new_with_app_event(init);
chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true);
(chat_widget, None)
}
SessionSelection::Resume(target_session) => {
if let Some(history_mode) = target_session.history_mode {
app_server.remember_thread_history_mode(target_session.thread_id, history_mode);
}
let model_settings = config_persistence::resume_model_settings_for_overrides(
&config,
&harness_overrides,
);
let resumed = match startup_draft
.run_until(
tui,
app_server.resume_thread(
config.clone(),
target_session.thread_id,
model_settings,
),
)
.await
{
Ok(resumed) => resumed
.map_err(|err| session_start_error("resume", &target_session, err))?,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
};
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
runtime_model_provider_base_url: runtime_model_provider_base_url.clone(),
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(resumed))
}
SessionSelection::Fork(target_session) => {
session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "cli_subcommand")],
);
let forked = match startup_draft
.run_until(
tui,
app_server.fork_thread(config.clone(), target_session.thread_id),
)
.await
{
Ok(forked) => {
forked.map_err(|err| session_start_error("fork", &target_session, err))?
}
Err(err) => return shutdown_on_startup_error(app_server, err).await,
};
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
runtime_model_provider_base_url: runtime_model_provider_base_url.clone(),
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(forked))
}
};
chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width);
chat_widget.remote_connection = remote_connection;
let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis();
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
Fix the config and retry.\n\
See the Codex keymap documentation for supported actions and examples."
)
})?;
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let mut app = Self {
model_catalog,
session_telemetry: session_telemetry.clone(),
app_event_tx,
chat_widget,
workspace_command_runner: Some(workspace_command_runner),
config,
launch_cwd,
state_db,
cli_kv_overrides,
harness_overrides,
loader_overrides,
cloud_config_bundle,
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
enhanced_keys_supported,
keymap: runtime_keymap,
key_chord_matcher: KeyChordMatcher::default(),
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
skill_load_warnings: SkillLoadWarningState::default(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: feedback.clone(),
feedback_audience,
environment_manager,
app_server_target,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start,
startup_protected_input_boundary: true,
startup_pending_protected_request: false,
rate_limit_hard_stop_generation: 0,
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
};
if let Some(entry) = startup_hooks_browser {
app.chat_widget.open_hooks_browser(entry);
}
app.update_visible_history_rows(tui.terminal.last_known_screen_size);
let initial_session_started_at = Instant::now();
if let Some(started) = initial_started_thread {
let thread_id = started.session.thread_id;
if started.blocks_direct_input {
app.mark_primary_thread_parent_owned(thread_id);
}
match startup_draft
.run_until(
tui,
app.enqueue_primary_thread_session(started.session, started.turns),
)
.await
{
Ok(result) => result?,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
}
if should_prompt_for_paused_goal_after_startup_resume
&& let Err(err) = startup_draft
.run_until(
tui,
app.maybe_prompt_resume_paused_goal_after_resume(
&mut app_server,
thread_id,
),
)
.await
{
return shutdown_on_startup_error(app_server, err).await;
}
}
let initial_session_ms = initial_session_started_at.elapsed().as_millis();
// On startup, if a managed filesystem sandbox is active, warn about
// world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
let startup_permission_profile = app.config.permissions.effective_permission_profile();
let should_check = crate::windows_sandbox::level_from_config(&app.config)
!= WindowsSandboxLevel::Disabled
&& managed_filesystem_sandbox_is_restricted(&startup_permission_profile)
&& !app
.config
.notices
.hide_world_writable_warning
.unwrap_or(false);
if should_check {
let cwd = app.config.cwd.clone();
let workspace_roots = app.config.effective_workspace_roots();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let tx = app.app_event_tx.clone();
let logs_base_dir = app.config.codex_home.clone();
Self::spawn_world_writable_scan(
cwd,
workspace_roots,
env_map,
logs_base_dir,
startup_permission_profile,
tx,
);
}
}
if let Err(err) = startup_draft.flush_pending_events(tui).await {
return shutdown_on_startup_error(app_server, err).await;
}
if Self::should_handle_active_thread_events(
wait_for_initial_session_configured,
app.active_thread_rx.is_some(),
) && let Err(err) = app.drain_active_thread_events(tui).await
{
return shutdown_on_startup_error(app_server, err).await;
}
if app_event_rx.is_empty()
&& !app.has_queued_startup_protected_request()
&& !app.startup_pending_protected_request
&& !app.chat_widget.has_active_view()
&& !app.chat_widget.has_pending_protected_request()
&& let Err(err) = startup_draft.flush_pending_paste_newline(tui).await
{
return shutdown_on_startup_error(app_server, err).await;
}
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);
}
let event_stream_started_at = Instant::now();
tui.schedule_screen_size_recheck(Duration::ZERO);
if let Err(err) = render_startup_frame(&mut app, tui) {
return shutdown_on_startup_error(app_server, err).await;
}
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
tracing::info!(
duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(),
bootstrap_ms = %bootstrap_ms,
runtime_model_provider_ms = %runtime_model_provider_ms,
thread_and_widget_ms = %thread_and_widget_ms,
initial_session_ms = %initial_session_ms,
event_stream_ms = %event_stream_started_at.elapsed().as_millis(),
"tui startup initial frame scheduled"
);
app.refresh_startup_skills(&app_server);
// Kick off a non-blocking rate-limit prefetch so the first `/status`
// already has data and available reset credits can be surfaced, without
// delaying the initial frame render.
if requires_openai_auth && has_chatgpt_account {
let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check();
app.refresh_rate_limits(
&app_server,
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
},
);
}
let mut listen_for_app_server_events = true;
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
let mut waiting_for_initial_session_header = true;
#[cfg(not(debug_assertions))]
let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version {
let control = Box::pin(app.handle_event(
tui,
&mut app_server,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
))),
))
.await?;
match control {
AppRunControl::Continue => None,
AppRunControl::Exit(exit_reason) => Some(exit_reason),
}
} else {
None
};
#[cfg(debug_assertions)]
let pre_loop_exit_reason: Option<ExitReason> = None;
let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason {
Ok(exit_reason)
} else {
loop {
let initial_session_header_pending = waiting_for_initial_session_header
&& app.primary_session_configured.is_some()
&& !app_event_rx.is_empty();
let block_terminal_input_for_pending_startup_events = initial_session_header_pending
|| (pending_startup_draft.is_some() || app.startup_protected_input_boundary)
&& !app_event_rx.is_empty()
|| (!waiting_for_initial_session_configured
&& app.has_queued_startup_protected_request());
let control = select! {
Some(event) = app_event_rx.recv() => {
let is_initial_session_header = matches!(
&event,
AppEvent::InsertHistoryCell(cell)
if cell.as_any().is::<history_cell::SessionInfoCell>()
);
let had_active_view = app.chat_widget.has_active_view();
match Box::pin(app.handle_event(tui, &mut app_server, event)).await {
Ok(AppRunControl::Continue) => {
if is_initial_session_header {
waiting_for_initial_session_header = false;
}
if !had_active_view
&& app.chat_widget.has_active_view()
&& let Err(err) = render_startup_frame(&mut app, tui)
{
break Err(err);
}
AppRunControl::Continue
}
Ok(AppRunControl::Exit(reason)) => AppRunControl::Exit(reason),
Err(err) => break Err(err),
}
}
active = async {
if let Some(rx) = app.active_thread_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if App::should_handle_active_thread_events(
waiting_for_initial_session_configured,
app.active_thread_rx.is_some()
) => {
if let Some(event) = active {
if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await {
break Err(err);
}
} else {
app.clear_active_thread().await;
}
AppRunControl::Continue
}
event = tui_events.next(), if !block_terminal_input_for_pending_startup_events => {
if let Some(event) = event {
if (matches!(
&event,
TuiEvent::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
) || matches!(&event, TuiEvent::Paste(_)))
&& pending_startup_draft.is_none()
&& !waiting_for_initial_session_configured
&& app_event_rx.is_empty()
&& !app.startup_pending_protected_request
&& app
.active_thread_rx
.as_ref()
.is_none_or(tokio::sync::mpsc::Receiver::is_empty)
&& !app.pending_primary_events.iter().any(|event| {
matches!(event, ThreadBufferedEvent::Request(_))
})
{
app.startup_protected_input_boundary = false;
}
match app.handle_tui_event(tui, &mut app_server, event).await {
Ok(control) => control,
Err(err) => break Err(err),
}
} else {
tracing::warn!("terminal input stream closed; shutting down active thread");
app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await
}
}
app_server_event = app_server.next_event(), if listen_for_app_server_events => {
match app_server_event {
Some(event) => app.handle_app_server_event(&app_server, event).await,
None => {
listen_for_app_server_events = false;
tracing::warn!("app-server event stream closed");
}
}
AppRunControl::Continue
}
};
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
) {
waiting_for_initial_session_configured = false;
let had_active_view = app.chat_widget.has_active_view();
if let Err(err) = app.drain_active_thread_events(tui).await {
break Err(err);
}
if !had_active_view
&& app.chat_widget.has_active_view()
&& let Err(err) = render_startup_frame(&mut app, tui)
{
break Err(err);
}
}
match control {
AppRunControl::Continue => {
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
}
}
AppRunControl::Exit(reason) => break Ok(reason),
}
}
};
if let Err(err) = app_server.shutdown().await {
tracing::warn!(error = %err, "failed to shut down embedded app server");
}
let clear_pet_result = tui.clear_ambient_pet_image();
let clear_result = tui.terminal.clear();
let exit_reason = match exit_reason_result {
Ok(exit_reason) => {
clear_pet_result?;
clear_result?;
exit_reason
}
Err(err) => {
if let Err(clear_pet_err) = clear_pet_result {
tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image");
}
if let Err(clear_err) = clear_result {
tracing::warn!(error = %clear_err, "failed to clear terminal UI");
}
return Err(err);
}
};
let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id);
let resume_hint = resume_hint_for_resumable_thread(
thread_id,
app.chat_widget.thread_name(),
app.chat_widget.rollout_path().as_deref(),
);
Ok(AppExitInfo {
token_usage: app.token_usage(),
thread_id,
resume_hint,
update_action: app.pending_update_action,
exit_reason,
})
}
}
+2
View File
@@ -71,6 +71,8 @@ pub(super) async fn make_test_app() -> App {
pending_primary_events: VecDeque::new(), pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(), pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false, pending_startup_thread_start: false,
startup_protected_input_boundary: false,
startup_pending_protected_request: false,
rate_limit_hard_stop_generation: 0, rate_limit_hard_stop_generation: 0,
pending_plugin_enabled_writes: HashMap::new(), pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(),
+4
View File
@@ -4843,6 +4843,8 @@ async fn make_test_app() -> App {
pending_primary_events: VecDeque::new(), pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(), pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false, pending_startup_thread_start: false,
startup_protected_input_boundary: false,
startup_pending_protected_request: false,
rate_limit_hard_stop_generation: 0, rate_limit_hard_stop_generation: 0,
pending_plugin_enabled_writes: HashMap::new(), pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(),
@@ -4916,6 +4918,8 @@ async fn make_test_app_with_channels() -> (
pending_primary_events: VecDeque::new(), pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(), pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false, pending_startup_thread_start: false,
startup_protected_input_boundary: false,
startup_pending_protected_request: false,
rate_limit_hard_stop_generation: 0, rate_limit_hard_stop_generation: 0,
pending_plugin_enabled_writes: HashMap::new(), pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(),
+658
View File
@@ -1,8 +1,33 @@
use super::*; use super::*;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::tui::FrameRequester;
use codex_app_server_protocol::ToolRequestUserInputOption;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver<AppEvent>) {
let (app_event_tx, app_event_rx) = unbounded_channel();
(
BottomPane::new(BottomPaneParams {
app_event_tx: AppEventSender::new(app_event_tx),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: true,
animations_enabled: true,
skills: None,
}),
app_event_rx,
)
}
#[test] #[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
@@ -141,6 +166,639 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
); );
} }
#[tokio::test]
async fn queued_startup_requests_block_terminal_input_after_draft_handoff() -> Result<()> {
for user_input in [false, true] {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.startup_protected_input_boundary = true;
let thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
while app_event_rx.try_recv().is_ok() {}
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert!(pending_startup_draft.is_none());
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft");
assert!(!app.has_queued_startup_protected_request());
let request = if user_input {
request_user_input_request(thread_id, "turn-1", "call-1")
} else {
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None)
};
let _ = app
.pending_app_server_requests
.note_server_request(&request);
app.enqueue_primary_thread_request(request).await?;
assert!(
app.active_thread_rx
.as_ref()
.is_some_and(|receiver| !receiver.is_empty())
);
assert!(
app.has_queued_startup_protected_request(),
"a queued protected startup request must be handled before terminal input"
);
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft");
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(
op,
Op::UserTurn { .. } | Op::ExecApproval { .. } | Op::UserInputAnswer { .. }
),
"terminal input must not submit or answer the queued protected request: {op:?}"
);
}
let event = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active")
.try_recv()
.expect("protected request should be queued on the active thread");
app.handle_thread_event_now(event);
assert!(
!app.has_queued_startup_protected_request(),
"terminal polling must resume after the protected event is drained"
);
assert!(app.startup_protected_input_boundary);
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft");
app.startup_protected_input_boundary = false;
app.enqueue_primary_thread_request(exec_approval_request(
thread_id, "turn-2", "call-2", /*approval_id*/ None,
))
.await?;
assert!(
!app.has_queued_startup_protected_request(),
"ordinary post-startup protected requests must not block terminal input"
);
}
Ok(())
}
#[tokio::test]
async fn queued_startup_requests_block_draft_restore_until_drained() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.startup_protected_input_boundary = true;
let thread_id = ThreadId::new();
let request = exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
let _ = app
.pending_app_server_requests
.note_server_request(&request);
app.enqueue_primary_thread_request(request).await?;
assert!(app.has_queued_startup_protected_request());
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
while app_event_rx.try_recv().is_ok() {}
assert!(app.has_queued_startup_protected_request());
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
}
assert!(pending_startup_draft.is_some());
assert!(app.chat_widget.composer_is_empty());
let event = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active")
.try_recv()
.expect("protected request should be queued on the active thread");
app.handle_thread_event_now(event);
assert!(!app.has_queued_startup_protected_request());
assert!(app.chat_widget.has_active_view());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert!(pending_startup_draft.is_some());
assert!(app.chat_widget.composer_is_empty());
Ok(())
}
#[tokio::test]
async fn startup_draft_handoff_keeps_approval_shortcuts_in_recently_active_composer() -> Result<()>
{
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert!(pending_startup_draft.is_none());
assert_eq!(app.chat_widget.composer_text_with_pending(), "x");
let approval_request =
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
let _ = app
.pending_app_server_requests
.note_server_request(&approval_request);
app.enqueue_primary_thread_request(approval_request).await?;
let approval_event = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active")
.try_recv()
.expect("approval should be queued on the active thread");
app.handle_thread_event_now(approval_event);
assert!(!app.chat_widget.has_active_view());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(app.chat_widget.composer_text_with_pending(), "xy");
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(event, AppEvent::SubmitThreadOp { .. }),
"startup typeahead should not approve a newly buffered request: {event:?}"
);
}
Ok(())
}
#[tokio::test]
async fn startup_draft_delayed_approval_becomes_protected_on_redraw() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.startup_protected_input_boundary = true;
let thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
let mut draft = startup_pane.composer_draft_snapshot();
draft.last_composer_activity_at = Some(Instant::now() - Duration::from_millis(/*millis*/ 950));
let mut pending_startup_draft = Some(draft);
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
let approval_request =
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
let _ = app
.pending_app_server_requests
.note_server_request(&approval_request);
app.enqueue_primary_thread_request(approval_request).await?;
let approval_event = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active")
.try_recv()
.expect("approval should be queued on the active thread");
app.handle_thread_event_now(approval_event);
assert!(!app.chat_widget.has_active_view());
assert!(app.startup_pending_protected_request);
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
)
.await?;
assert!(app.startup_protected_input_boundary);
assert!(app.startup_pending_protected_request);
tokio::time::sleep(Duration::from_millis(/*millis*/ 75)).await;
let redraw_result = app
.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
.await;
assert!(app.chat_widget.has_active_view());
assert!(!tui.terminal.viewport_area.is_empty());
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(event, AppEvent::SubmitThreadOp { .. }),
"revealing a delayed startup approval must not submit a decision: {event:?}"
);
}
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }),
"revealing a delayed startup approval must not answer it: {op:?}"
);
}
if let Err(error) = redraw_result {
tracing::debug!(error = %error, "test terminal cannot quarantine interactive input");
} else {
assert!(!app.startup_pending_protected_request);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn remotely_resolved_startup_approvals_release_the_draft_after_the_last_request() -> Result<()>
{
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.startup_protected_input_boundary = true;
let thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
app.chat_widget.handle_paste("x".to_string());
for (request_id, item_id) in [(1, "call-1"), (2, "call-2")] {
let mut request =
exec_approval_request(thread_id, "turn-1", item_id, /*approval_id*/ None);
let ServerRequest::CommandExecutionRequestApproval {
request_id: approval_request_id,
..
} = &mut request
else {
unreachable!("exec approval helper should return an exec approval");
};
*approval_request_id = AppServerRequestId::Integer(request_id);
let _ = app
.pending_app_server_requests
.note_server_request(&request);
app.handle_thread_event_now(ThreadBufferedEvent::Request(Box::new(request)));
}
assert!(!app.chat_widget.has_active_view());
assert!(app.startup_pending_protected_request);
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("startup draft".to_string(), Vec::new(), Vec::new());
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert!(pending_startup_draft.is_some());
for (request_id, still_pending) in [(1, true), (2, false)] {
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: thread_id.to_string(),
request_id: AppServerRequestId::Integer(request_id),
},
),
)),
)
.await;
assert_eq!(app.startup_pending_protected_request, still_pending);
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert_eq!(pending_startup_draft.is_some(), still_pending);
}
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"x\nstartup draft"
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn later_thread_approval_preserves_input_after_startup_boundary_ends() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
assert!(!app.startup_protected_input_boundary);
app.chat_widget
.apply_external_edit("keep this draft".to_string());
let approval_request =
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
let _ = app
.pending_app_server_requests
.note_server_request(&approval_request);
app.enqueue_primary_thread_request(approval_request).await?;
let approval_event = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active")
.try_recv()
.expect("approval should be queued on the active thread");
assert!(!app.chat_widget.has_active_view());
app.handle_active_thread_event(&mut tui, &mut app_server, approval_event)
.await?;
assert!(app.chat_widget.has_active_view());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"keep this draft"
);
assert!(tui.terminal.viewport_area.is_empty());
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(event, AppEvent::SubmitThreadOp { .. }),
"a later protected request must not consume existing input: {event:?}"
);
}
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }),
"a later protected request must not submit an answer: {op:?}"
);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn auto_declined_mcp_elicitations_do_not_leave_startup_quarantine_armed() {
for replay in [false, true] {
for elicitation in [
McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "http://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
},
McpServerElicitationRequest::OpenAiForm {
meta: None,
message: "Choose a report.".to_string(),
requested_schema: serde_json::json!({}),
},
] {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.startup_protected_input_boundary = true;
let thread_id = ThreadId::new();
let request = ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(10),
params: McpServerElicitationRequestParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-auth".to_string()),
server_name: "payments".to_string(),
request: elicitation,
},
};
let _ = app
.pending_app_server_requests
.note_server_request(&request);
let event = ThreadBufferedEvent::Request(Box::new(request));
if replay {
app.handle_thread_event_replay(event);
} else {
app.handle_thread_event_now(event);
}
assert!(!app.chat_widget.has_active_view());
assert!(!app.startup_pending_protected_request);
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
op: Op::ResolveElicitation {
server_name,
request_id: AppServerRequestId::Integer(10),
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
content: None,
meta: None,
},
}) if op_thread_id == thread_id && server_name == "payments"
);
}
}
}
#[tokio::test]
async fn startup_draft_handoff_recognizes_late_user_input_as_new_protected_view() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
let mut request = request_user_input_request(thread_id, "turn-1", "call-1");
let ServerRequest::ToolRequestUserInput { params, .. } = &mut request else {
panic!("expected a user input request");
};
params.questions.push(ToolRequestUserInputQuestion {
id: "choice".to_string(),
header: "Pick one".to_string(),
question: "Choose an option.".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![ToolRequestUserInputOption {
label: "First".to_string(),
description: "First option".to_string(),
}]),
});
let _ = app
.pending_app_server_requests
.note_server_request(&request);
app.enqueue_primary_thread_request(request).await?;
let event = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active")
.try_recv()
.expect("user input request should be queued on the active thread");
assert!(!app.chat_widget.has_active_view());
app.handle_thread_event_now(event);
assert!(app.chat_widget.has_active_view());
assert_eq!(app.chat_widget.composer_text_with_pending(), "x");
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(event, AppEvent::CodexOp(Op::UserInputAnswer { .. })),
"showing the protected user input request must not submit an answer: {event:?}"
);
}
Ok(())
}
#[tokio::test]
async fn fresh_startup_thread_drains_buffered_approval_before_draft_handoff() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.pending_startup_thread_start = true;
let thread_id = ThreadId::new();
let approval_request =
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
let _ = app
.pending_app_server_requests
.note_server_request(&approval_request);
app.enqueue_primary_thread_request(approval_request).await?;
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new());
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
let mut waiting_for_initial_session_configured =
App::should_wait_for_initial_session(&SessionSelection::StartFresh);
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::StartupThreadStarted {
result: Ok(AppServerStartedThread {
session: test_thread_session(thread_id, test_path_buf("/tmp/project")),
turns: Vec::new(),
blocks_direct_input: false,
}),
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert!(
app.active_thread_rx
.as_ref()
.is_some_and(|receiver| !receiver.is_empty())
);
assert!(!app.chat_widget.has_active_view());
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
) {
waiting_for_initial_session_configured = false;
app.drain_active_thread_events(&mut tui).await?;
}
assert!(!waiting_for_initial_session_configured);
assert!(app.chat_widget.has_active_view());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert_eq!(
pending_startup_draft
.as_ref()
.map(|draft| draft.text.as_str()),
Some("inspect @src")
);
assert!(app.chat_widget.composer_is_empty());
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }),
"showing a protected startup approval must not answer it: {op:?}"
);
}
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(
event,
AppEvent::StartFileSearch(_) | AppEvent::SubmitThreadOp { .. }
),
"fresh startup approval must own input before draft side effects: {event:?}"
);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn queued_startup_app_event_owns_protected_view_before_draft_restore() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new());
let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot());
app.app_event_tx.send(AppEvent::OpenApprovalsPopup);
if app_event_rx.is_empty() {
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
}
assert!(pending_startup_draft.is_some());
assert!(app.chat_widget.composer_is_empty());
let event = app_event_rx
.try_recv()
.expect("protected startup app event should be queued");
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
assert!(!app.chat_widget.has_active_view());
let control = Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
assert!(matches!(control, AppRunControl::Continue));
assert!(app.chat_widget.has_active_view());
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
assert_eq!(
pending_startup_draft
.as_ref()
.map(|draft| draft.text.as_str()),
Some("inspect @src")
);
assert!(app.chat_widget.composer_is_empty());
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(
event,
AppEvent::StartFileSearch(_)
| AppEvent::UpdateWorldWritableWarningAcknowledged(_)
| AppEvent::PersistWorldWritableWarningAcknowledged
),
"protected startup app event must own input before draft side effects: {event:?}"
);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn startup_thread_started_submits_queued_startup_input() { async fn startup_thread_started_submits_queued_startup_input() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
+55 -3
View File
@@ -353,6 +353,9 @@ impl App {
ThreadInteractiveRequest::Approval(request) => { ThreadInteractiveRequest::Approval(request) => {
self.render_inactive_patch_preview(&request); self.render_inactive_patch_preview(&request);
self.chat_widget.push_approval_request(request); self.chat_widget.push_approval_request(request);
if self.startup_protected_input_boundary && !self.chat_widget.has_active_view() {
self.startup_pending_protected_request = true;
}
} }
ThreadInteractiveRequest::McpServerElicitation(request) => { ThreadInteractiveRequest::McpServerElicitation(request) => {
self.chat_widget self.chat_widget
@@ -1570,6 +1573,28 @@ impl App {
self.chat_widget.handle_skills_list_response(response); self.chat_widget.handle_skills_list_response(response);
} }
fn startup_request_may_open_protected_view(&self, request: &ServerRequest) -> bool {
let ServerRequest::McpServerElicitationRequest { request_id, params } = request else {
return true;
};
match &params.request {
codex_app_server_protocol::McpServerElicitationRequest::Form { .. } => true,
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. } => false,
request @ codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
let thread_id = ThreadId::from_string(&params.thread_id)
.unwrap_or_else(|_| self.chat_widget.thread_id().unwrap_or_default());
AppLinkViewParams::from_url_app_server_request(
thread_id,
&params.server_name,
request_id.clone(),
request,
)
.is_some()
}
}
}
pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) {
let needs_refresh = matches!( let needs_refresh = matches!(
&event, &event,
@@ -1591,8 +1616,16 @@ impl App {
.pending_app_server_requests .pending_app_server_requests
.contains_server_request(request.as_ref()) .contains_server_request(request.as_ref())
{ {
let may_open_protected_view =
self.startup_request_may_open_protected_view(request.as_ref());
self.chat_widget self.chat_widget
.handle_server_request(*request, /*replay_kind*/ None); .handle_server_request(*request, /*replay_kind*/ None);
if may_open_protected_view
&& self.startup_protected_input_boundary
&& !self.chat_widget.has_active_view()
{
self.startup_pending_protected_request = true;
}
} }
} }
ThreadBufferedEvent::HistoryEntryResponse(event) => { ThreadBufferedEvent::HistoryEntryResponse(event) => {
@@ -1612,9 +1645,18 @@ impl App {
ThreadBufferedEvent::Notification(notification) => self ThreadBufferedEvent::Notification(notification) => self
.chat_widget .chat_widget
.handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot)), .handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot)),
ThreadBufferedEvent::Request(request) => self ThreadBufferedEvent::Request(request) => {
.chat_widget let may_open_protected_view =
.handle_server_request(*request, Some(ReplayKind::ThreadSnapshot)), self.startup_request_may_open_protected_view(request.as_ref());
self.chat_widget
.handle_server_request(*request, Some(ReplayKind::ThreadSnapshot));
if may_open_protected_view
&& self.startup_protected_input_boundary
&& !self.chat_widget.has_active_view()
{
self.startup_pending_protected_request = true;
}
}
ThreadBufferedEvent::HistoryEntryResponse(event) => { ThreadBufferedEvent::HistoryEntryResponse(event) => {
self.chat_widget.handle_history_entry_response(event) self.chat_widget.handle_history_entry_response(event)
} }
@@ -1686,7 +1728,17 @@ impl App {
// thread, so unrelated shutdowns cannot consume this marker. // thread, so unrelated shutdowns cannot consume this marker.
self.pending_shutdown_exit_thread_id = None; self.pending_shutdown_exit_thread_id = None;
} }
let had_active_view = self.chat_widget.has_active_view();
self.handle_thread_event_now(event); self.handle_thread_event_now(event);
if !had_active_view
&& self.chat_widget.has_active_view()
&& self.startup_protected_input_boundary
{
self.chat_widget.pre_draw_tick();
self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
tui.discard_pending_input_before_interactive_screen()?;
self.startup_pending_protected_request = false;
}
if self.backtrack_render_pending { if self.backtrack_render_pending {
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
+33 -1
View File
@@ -57,6 +57,16 @@
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after //! recall is a two-phase handoff: stage the submitted slash text here, then record it after
//! `ChatWidget` dispatches the command. //! `ChatWidget` dispatches the command.
//! //!
//! # 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
//! 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
//! and placeholder positions while preserving both composers' contents. The draft remains deferred
//! until protected views close, input is enabled, and required sandbox setup completes.
//!
//! # Submission and Prompt Expansion //! # Submission and Prompt Expansion
//! //!
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is //! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
@@ -529,11 +539,14 @@ struct ComposerDraft {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct ComposerDraftSnapshot { pub(crate) struct ComposerDraftSnapshot {
pub(crate) text: String, pub(crate) text: String,
pub(crate) cursor: usize,
pub(crate) text_elements: Vec<TextElement>, pub(crate) text_elements: Vec<TextElement>,
pub(crate) local_images: Vec<LocalImageAttachment>, pub(crate) local_images: Vec<LocalImageAttachment>,
pub(crate) remote_image_urls: Vec<String>, pub(crate) remote_image_urls: Vec<String>,
pub(crate) mention_bindings: Vec<MentionBinding>, pub(crate) mention_bindings: Vec<MentionBinding>,
pub(crate) pending_pastes: Vec<(String, String)>, pub(crate) pending_pastes: Vec<(String, String)>,
pub(crate) startup_local_history: Vec<HistoryEntry>,
pub(crate) last_composer_activity_at: Option<Instant>,
} }
const FOOTER_SPACING_HEIGHT: u16 = 0; const FOOTER_SPACING_HEIGHT: u16 = 0;
@@ -1298,6 +1311,22 @@ impl ChatComposer {
self.footer.mode = reset_mode_after_activity(self.footer.mode); self.footer.mode = reset_mode_after_activity(self.footer.mode);
} }
/// Enable Vim while keeping already-active text entry in insert mode.
pub(crate) fn enable_vim_in_insert_mode(&mut self) {
self.set_vim_enabled(/*enabled*/ true);
self.draft.textarea.enter_vim_insert_mode();
}
/// Restore draft history transferred from the startup composer.
pub(crate) fn restore_startup_local_history(
&mut self,
startup_local_history: Vec<HistoryEntry>,
) {
for entry in startup_local_history {
self.history.record_local_submission(entry);
}
}
/// Toggle Vim editing and return the new enabled state. /// Toggle Vim editing and return the new enabled state.
/// ///
/// This is the app-level command target for the configurable Vim toggle /// This is the app-level command target for the configurable Vim toggle
@@ -1532,7 +1561,7 @@ impl ChatComposer {
} }
} }
fn set_current_cursor(&mut self, cursor: usize) { pub(crate) fn set_current_cursor(&mut self, cursor: usize) {
let visible_cursor = if self.draft.is_bash_mode { let visible_cursor = if self.draft.is_bash_mode {
cursor.saturating_sub(1) cursor.saturating_sub(1)
} else { } else {
@@ -1716,11 +1745,14 @@ impl ChatComposer {
pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot { pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot {
ComposerDraftSnapshot { ComposerDraftSnapshot {
text: self.current_text(), text: self.current_text(),
cursor: self.current_cursor(),
text_elements: self.text_elements(), text_elements: self.text_elements(),
local_images: self.local_images(), local_images: self.local_images(),
remote_image_urls: self.remote_image_urls(), remote_image_urls: self.remote_image_urls(),
mention_bindings: self.mention_bindings(), mention_bindings: self.mention_bindings(),
pending_pastes: self.pending_pastes(), pending_pastes: self.pending_pastes(),
startup_local_history: self.history.startup_local_history().to_vec(),
last_composer_activity_at: None,
} }
} }
@@ -280,15 +280,17 @@ impl ChatComposerHistory {
/// Updates persistent history metadata when a new session is configured. /// Updates persistent history metadata when a new session is configured.
/// ///
/// This clears fetched entries, local entries, navigation cursors, and active search state /// Startup-local entries survive the first session configuration because they were recorded
/// because offsets only make sense within one history log snapshot. Reusing old offsets after a /// before a thread existed. Later configurations clear local history, while every configuration
/// log-id change would allow a stale async response to hydrate the wrong prompt. /// resets fetched entries, navigation cursors, and search state tied to the old history log.
pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) {
self.thread_id = Some(thread_id); let had_configured_thread = self.thread_id.replace(thread_id).is_some();
self.persistent_log_id = Some(log_id); self.persistent_log_id = Some(log_id);
self.persistent_entry_count = entry_count; self.persistent_entry_count = entry_count;
self.fetched_history.clear(); self.fetched_history.clear();
self.local_history.clear(); if had_configured_thread {
self.local_history.clear();
}
self.replay_seeded_history.clear(); self.replay_seeded_history.clear();
self.history_cursor = None; self.history_cursor = None;
self.pending_navigation_direction = None; self.pending_navigation_direction = None;
@@ -296,6 +298,15 @@ impl ChatComposerHistory {
self.search = None; self.search = None;
} }
/// Return draft history recorded before the composer became associated with a thread.
pub(crate) fn startup_local_history(&self) -> &[HistoryEntry] {
if self.thread_id.is_none() {
&self.local_history
} else {
&[]
}
}
/// Records a current-session submission so it can be recalled with full draft metadata. /// Records a current-session submission so it can be recalled with full draft metadata.
/// ///
/// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or /// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or
@@ -1002,6 +1013,30 @@ mod tests {
); );
} }
#[test]
fn initial_metadata_preserves_startup_history_but_session_changes_clear_it() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
let startup_entry = HistoryEntry::new("cleared during startup".to_string());
history.record_local_submission(startup_entry.clone());
assert_eq!(
history.startup_local_history(),
std::slice::from_ref(&startup_entry)
);
history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 0);
assert!(history.startup_local_history().is_empty());
assert_eq!(history.navigate_up(&tx), Some(startup_entry));
history.record_local_submission(HistoryEntry::new("thread-owned draft".to_string()));
history.set_metadata(ThreadId::new(), /*log_id*/ 2, /*entry_count*/ 0);
assert_eq!(history.navigate_up(&tx), None);
}
#[test] #[test]
fn persistent_restore_gates_at_mentions() { fn persistent_restore_gates_at_mentions() {
let (tx, _rx) = unbounded_channel::<AppEvent>(); let (tx, _rx) = unbounded_channel::<AppEvent>();
+14 -7
View File
@@ -156,6 +156,7 @@ mod scroll_state;
mod selection_popup_common; mod selection_popup_common;
mod selection_row_layout; mod selection_row_layout;
mod selection_tabs; mod selection_tabs;
mod startup;
mod textarea; mod textarea;
mod unified_exec_footer; mod unified_exec_footer;
pub(crate) use feedback_view::FeedbackNoteView; pub(crate) use feedback_view::FeedbackNoteView;
@@ -194,6 +195,7 @@ pub(crate) enum CancellationEvent {
use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::ComposerDraftSnapshot;
pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::InputResult;
pub(crate) use chat_composer::QueuedInputAction; pub(crate) use chat_composer::QueuedInputAction;
pub(crate) use chat_composer_history::HistoryEntry; pub(crate) use chat_composer_history::HistoryEntry;
@@ -267,6 +269,14 @@ pub(crate) struct BottomPaneParams {
impl BottomPane { impl BottomPane {
pub fn new(params: BottomPaneParams) -> Self { pub fn new(params: BottomPaneParams) -> Self {
Self::new_with_composer_config(params, ChatComposerConfig::default())
}
/// Construct a bottom pane with explicitly restricted composer behavior.
pub(crate) fn new_with_composer_config(
params: BottomPaneParams,
composer_config: ChatComposerConfig,
) -> Self {
let BottomPaneParams { let BottomPaneParams {
app_event_tx, app_event_tx,
frame_requester, frame_requester,
@@ -277,12 +287,13 @@ impl BottomPane {
animations_enabled, animations_enabled,
skills, skills,
} = params; } = params;
let mut composer = ChatComposer::new( let mut composer = ChatComposer::new_with_config(
has_input_focus, has_input_focus,
app_event_tx.clone(), app_event_tx.clone(),
enhanced_keys_supported, enhanced_keys_supported,
placeholder_text, placeholder_text,
disable_paste_burst, disable_paste_burst,
composer_config,
); );
composer.set_frame_requester(frame_requester.clone()); composer.set_frame_requester(frame_requester.clone());
let keymap = RuntimeKeymap::defaults(); let keymap = RuntimeKeymap::defaults();
@@ -577,7 +588,7 @@ impl BottomPane {
fn record_composer_activity_at(&mut self, now: Instant) { fn record_composer_activity_at(&mut self, now: Instant) {
self.last_composer_activity_at = Some(now); self.last_composer_activity_at = Some(now);
if !self.delayed_approval_requests.is_empty() if self.has_pending_approval()
&& let Some(delay) = self.approval_prompt_delay_remaining(now) && let Some(delay) = self.approval_prompt_delay_remaining(now)
{ {
self.request_redraw_in(delay); self.request_redraw_in(delay);
@@ -585,7 +596,7 @@ impl BottomPane {
} }
fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) { fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) {
if self.delayed_approval_requests.is_empty() || !self.view_stack.is_empty() { if !self.has_pending_approval() || !self.view_stack.is_empty() {
return; return;
} }
if let Some(delay) = self.approval_prompt_delay_remaining(now) { if let Some(delay) = self.approval_prompt_delay_remaining(now) {
@@ -884,10 +895,6 @@ impl BottomPane {
self.composer.cursor() self.composer.cursor()
} }
pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot {
self.composer.draft_snapshot()
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> { pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> {
self.composer.text_elements() self.composer.text_elements()
+113
View File
@@ -0,0 +1,113 @@
//! 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,
//! composer history and cursor state, and the approval-idle timing inherited by the real composer.
use std::time::Instant;
use crossterm::event::KeyEvent;
use super::BottomPane;
use super::ComposerDraftSnapshot;
use super::HistoryEntry;
use crate::key_hint::KeyBindingListExt;
impl BottomPane {
/// 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;
[
&composer.submit,
&composer.queue,
&composer.history_search_previous,
&composer.history_search_next,
]
.into_iter()
.any(|bindings| bindings.is_pressed(key_event))
|| self.composer_is_empty() && composer.toggle_shortcuts.is_pressed(key_event)
}
/// 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;
[
&editor.insert_newline,
&editor.move_left,
&editor.move_right,
&editor.move_up,
&editor.move_down,
&editor.move_word_left,
&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))
}
/// Enable Vim without interrupting an in-progress startup draft.
pub(crate) fn enable_vim_in_insert_mode(&mut self) {
self.composer.enable_vim_in_insert_mode();
self.request_redraw();
}
/// Apply paste-burst preferences without dropping input buffered before config loading.
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
self.disable_paste_burst = disabled;
self.composer.set_disable_paste_burst(disabled);
}
/// Capture draft content together with the activity timestamp used by delayed approvals.
pub(crate) fn composer_draft_snapshot(&self) -> ComposerDraftSnapshot {
let mut draft = self.composer.draft_snapshot();
draft.last_composer_activity_at = self.last_composer_activity_at;
draft
}
/// 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);
self.composer.sync_popups();
}
/// Preserve startup-local history and the newest protected-prompt activity timestamp.
pub(crate) fn restore_startup_composer_state(
&mut self,
last_composer_activity_at: Option<Instant>,
startup_local_history: Vec<HistoryEntry>,
) {
self.composer
.restore_startup_local_history(startup_local_history);
if let Some(last_composer_activity_at) = last_composer_activity_at {
let last_composer_activity_at = self
.last_composer_activity_at
.map_or(last_composer_activity_at, |current| {
current.max(last_composer_activity_at)
});
self.record_composer_activity_at(last_composer_activity_at);
}
}
/// Return whether an approval is waiting to become visible after typing stops.
pub(crate) fn has_pending_approval(&self) -> bool {
!self.delayed_approval_requests.is_empty()
}
/// Preserve buffered composer input before its owner or input mode changes.
pub(crate) fn flush_composer_paste_burst(&mut self) {
if self.composer.is_in_paste_burst() {
self.composer.set_disable_paste_burst(/*disabled*/ true);
self.composer
.set_disable_paste_burst(self.disable_paste_burst);
}
}
}
-1
View File
@@ -1738,7 +1738,6 @@ impl ChatWidget {
self.bottom_pane.pending_thread_approvals() self.bottom_pane.pending_thread_approvals()
} }
#[cfg(test)]
pub(crate) fn has_active_view(&self) -> bool { pub(crate) fn has_active_view(&self) -> bool {
self.bottom_pane.has_active_view() self.bottom_pane.has_active_view()
} }
@@ -3,10 +3,116 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::bottom_pane::ComposerDraftSnapshot;
use super::user_messages::remap_colliding_paste_placeholders; use super::user_messages::remap_colliding_paste_placeholders;
use super::*; use super::*;
impl ChatWidget { impl ChatWidget {
/// Restore the exact draft entered before the fully initialized composer became available.
pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) {
let existing_draft = self.bottom_pane.composer_draft_snapshot();
let existing_cursor = existing_draft.cursor;
let existing_message = UserMessage {
text: existing_draft.text,
text_elements: existing_draft.text_elements,
local_images: existing_draft.local_images,
remote_image_urls: existing_draft.remote_image_urls,
mention_bindings: existing_draft.mention_bindings,
};
let existing_has_content =
!self.bottom_pane.composer_is_empty() || !existing_draft.pending_pastes.is_empty();
let startup_message = UserMessage {
text: draft.text,
text_elements: draft.text_elements,
local_images: draft.local_images,
remote_image_urls: draft.remote_image_urls,
mention_bindings: draft.mention_bindings,
};
let startup_has_content = !startup_message.text.is_empty()
|| !startup_message.local_images.is_empty()
|| !startup_message.remote_image_urls.is_empty()
|| !draft.pending_pastes.is_empty();
let cursor = if existing_has_content && startup_has_content {
let mut used_paste_placeholders = HashSet::new();
let (existing_message, mut pending_pastes) = remap_colliding_paste_placeholders(
existing_message,
existing_draft.pending_pastes,
&mut used_paste_placeholders,
);
let startup_offset = existing_message.text.len().saturating_add(1);
let preceding_text_element = startup_message
.text_elements
.iter()
.enumerate()
.take_while(|(_, element)| element.byte_range.end <= draft.cursor)
.last()
.map(|(index, element)| (index, element.byte_range.end));
let (startup_message, startup_pending_pastes) = remap_colliding_paste_placeholders(
startup_message,
draft.pending_pastes,
&mut used_paste_placeholders,
);
let cursor_adjustment = preceding_text_element.map_or(0, |(index, original_end)| {
startup_message.text_elements[index]
.byte_range
.end
.saturating_sub(original_end)
});
pending_pastes.extend(startup_pending_pastes);
self.restore_composer_state(Self::composer_state_from_user_message(
merge_user_messages(vec![existing_message, startup_message]),
pending_pastes,
));
startup_offset
.saturating_add(draft.cursor)
.saturating_add(cursor_adjustment)
} else if existing_has_content {
existing_cursor
} else {
self.restore_composer_state(Self::composer_state_from_user_message(
startup_message,
draft.pending_pastes,
));
draft.cursor
};
self.bottom_pane.set_composer_cursor(cursor);
self.bottom_pane.restore_startup_composer_state(
draft.last_composer_activity_at,
draft.startup_local_history,
);
if startup_has_content && self.config.tui_vim_mode_default {
self.bottom_pane.enable_vim_in_insert_mode();
}
}
/// Includes protected prompts deferred by streaming or the approval idle timer.
pub(crate) fn has_pending_protected_request(&self) -> bool {
self.bottom_pane.has_pending_approval() || self.interrupts.has_pending_prompt()
}
/// Transfer startup input only after protected views and required sandbox setup finish.
pub(crate) fn restore_startup_draft_when_ready(
&mut self,
pending_draft: &mut Option<ComposerDraftSnapshot>,
) {
if self.has_active_view()
|| self.has_pending_protected_request()
|| !self.bottom_pane.composer_input_enabled()
{
return;
}
#[cfg(any(target_os = "windows", test))]
if self.elevated_windows_sandbox_setup_required() {
return;
}
if let Some(draft) = pending_draft.take() {
self.restore_startup_draft(draft);
}
}
pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) {
self.suppress_initial_user_message_submit = suppressed; self.suppress_initial_user_message_submit = suppressed;
} }
+26
View File
@@ -44,6 +44,20 @@ impl InterruptManager {
self.queue.is_empty() self.queue.is_empty()
} }
/// Excludes lifecycle events that never claim protected interactive input.
pub(crate) fn has_pending_prompt(&self) -> bool {
self.queue.iter().any(|interrupt| {
matches!(
interrupt,
QueuedInterrupt::ExecApproval(_)
| QueuedInterrupt::ApplyPatchApproval(_)
| QueuedInterrupt::Elicitation { .. }
| QueuedInterrupt::RequestPermissions(_)
| QueuedInterrupt::RequestUserInput(_)
)
})
}
pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) { pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) {
self.queue.push_back(QueuedInterrupt::ExecApproval(ev)); self.queue.push_back(QueuedInterrupt::ExecApproval(ev));
} }
@@ -80,6 +94,9 @@ impl InterruptManager {
} }
pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool { pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool {
if !self.has_pending_prompt() {
return false;
}
let original_len = self.queue.len(); let original_len = self.queue.len();
self.queue self.queue
.retain(|queued| !queued.matches_resolved_prompt(request)); .retain(|queued| !queued.matches_resolved_prompt(request));
@@ -196,6 +213,7 @@ mod tests {
let mut manager = InterruptManager::new(); let mut manager = InterruptManager::new();
manager.push_user_input(user_input("call-a", "turn")); manager.push_user_input(user_input("call-a", "turn"));
manager.push_user_input(user_input("call-b", "turn")); manager.push_user_input(user_input("call-b", "turn"));
assert!(manager.has_pending_prompt());
assert!( assert!(
manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput { manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput {
@@ -204,10 +222,17 @@ mod tests {
); );
assert_eq!(manager.queue.len(), 1); assert_eq!(manager.queue.len(), 1);
assert!(manager.has_pending_prompt());
let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else { let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else {
panic!("expected remaining queued user input"); panic!("expected remaining queued user input");
}; };
assert_eq!(remaining.item_id, "call-a"); assert_eq!(remaining.item_id, "call-a");
assert!(
manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput {
call_id: "call-a".to_string(),
})
);
assert!(!manager.has_pending_prompt());
} }
#[test] #[test]
@@ -234,6 +259,7 @@ mod tests {
fn remove_resolved_prompt_keeps_lifecycle_events() { fn remove_resolved_prompt_keeps_lifecycle_events() {
let mut manager = InterruptManager::new(); let mut manager = InterruptManager::new();
manager.push_item_started(command_execution("call")); manager.push_item_started(command_execution("call"));
assert!(!manager.has_pending_prompt());
assert!( assert!(
!manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval { !manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval {
@@ -24,7 +24,9 @@ async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); let mut after = chat.bottom_pane.composer_draft_snapshot();
after.last_composer_activity_at = before.last_composer_activity_at;
assert_eq!(after, before);
assert_no_submit_op(&mut op_rx); assert_no_submit_op(&mut op_rx);
let rendered = drain_insert_history(&mut rx) let rendered = drain_insert_history(&mut rx)
.into_iter() .into_iter()
@@ -56,7 +58,9 @@ async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
.set_composer_text(command.to_string(), Vec::new(), Vec::new()); .set_composer_text(command.to_string(), Vec::new(), Vec::new());
let before = chat.bottom_pane.composer_draft_snapshot(); let before = chat.bottom_pane.composer_draft_snapshot();
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); let mut after = chat.bottom_pane.composer_draft_snapshot();
after.last_composer_activity_at = before.last_composer_activity_at;
assert_eq!(after, before);
assert_no_submit_op(&mut op_rx); assert_no_submit_op(&mut op_rx);
} }
@@ -120,6 +124,22 @@ async fn parent_owned_thread_restores_pending_initial_prompt() {
assert_eq!(chat.bottom_pane.composer_text(), pending_prompt); assert_eq!(chat.bottom_pane.composer_text(), pending_prompt);
assert!(chat.initial_user_message.is_none()); assert!(chat.initial_user_message.is_none());
let (mut startup_chat, _startup_rx, _startup_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
startup_chat.bottom_pane.set_composer_text(
"typed during startup".to_string(),
Vec::new(),
Vec::new(),
);
let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot());
chat.restore_startup_draft_when_ready(&mut pending_draft);
assert!(pending_draft.is_none());
assert_eq!(
chat.bottom_pane.composer_text(),
format!("{pending_prompt}\ntyped during startup")
);
assert_no_submit_op(&mut op_rx); assert_no_submit_op(&mut op_rx);
} }
@@ -833,6 +853,267 @@ async fn blocked_image_restore_with_remote_images_keeps_local_placeholder_mappin
assert_eq!(chat.remote_image_urls(), remote_image_urls); assert_eq!(chat.remote_image_urls(), remote_image_urls);
} }
#[tokio::test]
async fn startup_draft_handoff_preserves_cursor_and_large_paste() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let paste = "x".repeat(/*n*/ 1_001);
chat.bottom_pane.handle_paste(paste);
chat.bottom_pane.insert_str(" tail");
chat.bottom_pane.set_composer_cursor(/*cursor*/ 0);
let startup_draft = chat.bottom_pane.composer_draft_snapshot();
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
chat.restore_startup_draft(startup_draft.clone());
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), startup_draft);
}
#[tokio::test]
async fn startup_draft_handoff_merges_existing_prompt_images_and_large_pastes() {
let (mut startup_chat, _startup_rx, _startup_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
let startup_paste = "x".repeat(/*n*/ 1_001);
startup_chat.bottom_pane.handle_paste(startup_paste.clone());
startup_chat.bottom_pane.insert_str(" startup draft");
startup_chat.bottom_pane.set_composer_cursor(/*cursor*/ 0);
let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let image_placeholder = "[Image #1]";
let image_path = PathBuf::from("/tmp/initial-prompt.png");
chat.bottom_pane.set_composer_text(
format!("{image_placeholder} initial prompt "),
vec![TextElement::new(
(0..image_placeholder.len()).into(),
Some(image_placeholder.to_string()),
)],
vec![image_path.clone()],
);
let initial_paste = "y".repeat(/*n*/ 1_001);
chat.bottom_pane.handle_paste(initial_paste.clone());
let existing_draft = chat.bottom_pane.composer_draft_snapshot();
let existing_placeholder = existing_draft.pending_pastes[0].0.clone();
chat.restore_startup_draft(startup_draft);
let restored = chat.bottom_pane.composer_draft_snapshot();
assert_eq!(
(
restored.text,
restored.cursor,
restored.pending_pastes,
restored.local_images,
),
(
format!(
"{}\n{existing_placeholder} #2 startup draft",
existing_draft.text
),
existing_draft.text.len() + 1,
vec![
(existing_placeholder.clone(), initial_paste),
(format!("{existing_placeholder} #2"), startup_paste),
],
vec![LocalImageAttachment {
placeholder: image_placeholder.to_string(),
path: image_path,
}],
)
);
}
#[tokio::test]
async fn startup_draft_handoff_rebases_cursor_around_colliding_large_pastes() {
for (cursor_marker, expected_added_bytes) in [("é", 0), (" 中 ", 3), (" tail", 6)] {
let (mut startup_chat, _startup_rx, _startup_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
startup_chat.bottom_pane.insert_str("é");
startup_chat
.bottom_pane
.handle_paste("x".repeat(/*n*/ 1_001));
startup_chat.bottom_pane.insert_str(" 中 ");
startup_chat
.bottom_pane
.handle_paste("y".repeat(/*n*/ 1_002));
startup_chat.bottom_pane.insert_str(" tail");
let startup_text = startup_chat.bottom_pane.composer_text();
let startup_cursor = startup_text
.find(cursor_marker)
.expect("cursor marker should be present")
+ cursor_marker.len();
startup_chat.bottom_pane.set_composer_cursor(startup_cursor);
let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.handle_paste("a".repeat(/*n*/ 1_001));
chat.bottom_pane.insert_str(" ");
chat.bottom_pane.handle_paste("b".repeat(/*n*/ 1_002));
let existing_text = chat.bottom_pane.composer_text();
chat.restore_startup_draft(startup_draft);
let first_placeholder = "[Pasted Content 1001 chars] #2";
let second_placeholder = "[Pasted Content 1002 chars] #2";
let expected_text =
format!("{existing_text}\né{first_placeholder} 中 {second_placeholder} tail");
let expected_cursor = existing_text.len() + 1 + startup_cursor + expected_added_bytes;
assert_eq!(chat.bottom_pane.composer_text(), expected_text);
assert_eq!(chat.bottom_pane.composer_cursor(), expected_cursor);
chat.bottom_pane.insert_str("✓");
let mut expected_edited_text = expected_text;
expected_edited_text.insert(expected_cursor, '✓');
assert_eq!(chat.bottom_pane.composer_text(), expected_edited_text);
}
}
#[tokio::test]
async fn startup_draft_handoff_preserves_cleared_history_across_session_configuration() {
for configure_before_restore in [false, true] {
let (mut startup_chat, _startup_rx, _startup_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
for text in ["first startup draft", "second startup draft"] {
startup_chat
.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
startup_chat.bottom_pane.on_ctrl_c();
}
let paste = "x".repeat(/*n*/ 1_001);
startup_chat.bottom_pane.handle_paste(paste.clone());
let placeholder = startup_chat.bottom_pane.composer_text();
startup_chat.bottom_pane.on_ctrl_c();
let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot());
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
if configure_before_restore {
chat.bottom_pane.set_history_metadata(
ThreadId::new(),
/*log_id*/ 1,
/*entry_count*/ 0,
);
}
chat.restore_startup_draft_when_ready(&mut pending_draft);
if !configure_before_restore {
chat.bottom_pane.set_history_metadata(
ThreadId::new(),
/*log_id*/ 1,
/*entry_count*/ 0,
);
}
assert!(pending_draft.is_none());
assert!(chat.bottom_pane.composer_is_empty());
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let restored = chat.bottom_pane.composer_draft_snapshot();
assert_eq!(
(
restored.text,
restored.text_elements,
restored.pending_pastes
),
(
placeholder.clone(),
vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.clone()),
)],
vec![(placeholder, paste)],
)
);
for expected in ["second startup draft", "first startup draft"] {
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), expected);
}
}
}
#[tokio::test]
async fn startup_draft_handoff_keeps_vim_insert_mode_for_nonempty_drafts() {
for (text, expected) in [("draft", "draftx"), ("", "")] {
let (mut startup_chat, _startup_rx, _startup_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
startup_chat.bottom_pane.insert_str(text);
let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_vim_mode_default = true;
chat.bottom_pane.set_vim_enabled(/*enabled*/ true);
chat.restore_startup_draft(startup_draft);
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), expected);
}
}
#[tokio::test]
async fn startup_draft_handoff_syncs_file_search_with_restored_interior_cursor() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let text = "inspect @src before continuing";
let cursor = text.find(" before").expect("file token has trailing text");
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.bottom_pane.set_composer_cursor(cursor);
let startup_draft = chat.bottom_pane.composer_draft_snapshot();
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
while rx.try_recv().is_ok() {}
chat.restore_startup_draft(startup_draft.clone());
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), startup_draft);
assert!(!chat.bottom_pane.no_modal_or_popup_active());
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::StartFileSearch(query) if query == "src"))
);
}
#[tokio::test]
async fn startup_draft_file_search_waits_for_protected_view_and_enabled_input() {
let (mut startup_chat, _startup_rx, _startup_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
startup_chat
.bottom_pane
.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new());
let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot());
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.open_approvals_popup();
while rx.try_recv().is_ok() {}
chat.restore_startup_draft_when_ready(&mut pending_draft);
assert!(chat.has_active_view());
assert!(pending_draft.is_some());
assert!(rx.try_recv().is_err());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!chat.has_active_view());
chat.bottom_pane
.set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None);
chat.restore_startup_draft_when_ready(&mut pending_draft);
assert!(pending_draft.is_some());
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.all(|event| !matches!(event, AppEvent::StartFileSearch(_)))
);
chat.bottom_pane
.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
chat.restore_startup_draft_when_ready(&mut pending_draft);
assert!(pending_draft.is_none());
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::StartFileSearch(query) if query == "src"))
);
}
#[tokio::test] #[tokio::test]
async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() { async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+379 -534
View File
File diff suppressed because it is too large. Load diff
+25 -9
View File
@@ -49,7 +49,7 @@ struct ProviderOption {
} }
#[derive(Clone)] #[derive(Clone)]
enum ProviderStatus { pub(crate) enum ProviderStatus {
Running, Running,
NotRunning, NotRunning,
Unknown, Unknown,
@@ -318,7 +318,16 @@ pub(crate) struct OssProviderSelection {
pub(crate) manually_selected: bool, pub(crate) manually_selected: bool,
} }
pub async fn select_oss_provider() -> io::Result<OssProviderSelection> { pub(crate) enum OssProviderDetection {
AutoSelected(OssProviderSelection),
NeedsSelection {
lmstudio_status: ProviderStatus,
ollama_status: ProviderStatus,
},
}
/// Probe local providers without suspending the interactive startup composer.
pub(crate) async fn detect_oss_provider() -> OssProviderDetection {
// These probes intentionally bypass proxy discovery because both targets are // These probes intentionally bypass proxy discovery because both targets are
// hardcoded plaintext loopback endpoints. Preserve the legacy custom-CA fallback so an // hardcoded plaintext loopback endpoints. Preserve the legacy custom-CA fallback so an
// invalid inherited certificate bundle cannot prevent best-effort provider detection. // invalid inherited certificate bundle cannot prevent best-effort provider detection.
@@ -333,23 +342,30 @@ pub async fn select_oss_provider() -> io::Result<OssProviderSelection> {
match (&lmstudio_status, &ollama_status) { match (&lmstudio_status, &ollama_status) {
(ProviderStatus::Running, ProviderStatus::NotRunning) => { (ProviderStatus::Running, ProviderStatus::NotRunning) => {
let provider = LMSTUDIO_OSS_PROVIDER_ID.to_string(); let provider = LMSTUDIO_OSS_PROVIDER_ID.to_string();
return Ok(OssProviderSelection { OssProviderDetection::AutoSelected(OssProviderSelection {
provider, provider,
manually_selected: false, manually_selected: false,
}); })
} }
(ProviderStatus::NotRunning, ProviderStatus::Running) => { (ProviderStatus::NotRunning, ProviderStatus::Running) => {
let provider = OLLAMA_OSS_PROVIDER_ID.to_string(); let provider = OLLAMA_OSS_PROVIDER_ID.to_string();
return Ok(OssProviderSelection { OssProviderDetection::AutoSelected(OssProviderSelection {
provider, provider,
manually_selected: false, manually_selected: false,
}); })
}
_ => {
// Both running or both not running - show UI
} }
_ => OssProviderDetection::NeedsSelection {
lmstudio_status,
ollama_status,
},
} }
}
/// Run the actionable provider picker after provider discovery requires a user decision.
pub(crate) async fn select_oss_provider(
lmstudio_status: ProviderStatus,
ollama_status: ProviderStatus,
) -> io::Result<OssProviderSelection> {
let mut widget = OssSelectionWidget::new(lmstudio_status, ollama_status)?; let mut widget = OssSelectionWidget::new(lmstudio_status, ollama_status)?;
enable_raw_mode()?; enable_raw_mode()?;
@@ -0,0 +1,44 @@
---
source: tui/src/startup_draft_tests.rs
expression: "snapshots.join(\"\\n---\\n\")"
---
empty (48 columns):
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› Ask Codex to do anything
? for shortcuts
---
multiline (48 columns):
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› first startup line
second startup line
---
narrow (18 columns):
╭────────────────╮
│ >_ OpenAI Cod… │
│ │
│ model: lo… │
│ directory: …ng │
╰────────────────╯
› first startup
line
second startup
line
+461
View File
@@ -0,0 +1,461 @@
//! Display an editable, non-submitting composer while startup work continues.
use std::future::Future;
use std::io;
use std::path::PathBuf;
use std::pin::Pin;
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 ratatui::layout::Size;
use ratatui::style::Modifier;
use ratatui::style::Style;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
use tokio_stream::Stream;
use tokio_stream::StreamExt;
use crate::TerminalRestoreGuard;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::ChatComposer;
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;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt;
use crate::render::renderable::RenderableItem;
use crate::tui;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crate::version::CODEX_CLI_VERSION;
const STARTUP_EVENT_BATCH_SIZE: usize = 64;
const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120);
/// Identifies the first interactive surface expected for the current invocation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftInitialScreen {
Composer,
SessionPicker,
}
/// Marks intentional startup cancellation so unrelated I/O interrupts remain errors.
#[derive(Debug, thiserror::Error)]
#[error("startup cancelled")]
pub(crate) struct StartupCancelled;
impl StartupCancelled {
/// Distinguish intentional user cancellation from unrelated interrupted I/O.
pub(crate) fn matches(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::Interrupted
&& error.get_ref().is_some_and(<dyn std::error::Error + std::marker::Send + std::marker::Sync + 'static>::is::<Self>)
}
}
/// Owns a real composer without allowing prompts, commands, or image access before startup.
pub(crate) struct StartupDraft {
tui: Tui,
terminal_restore_guard: TerminalRestoreGuard,
pump: StartupDraftPump,
}
/// Keeps the existing terminal responsive without owning it or permitting startup submission.
pub(crate) struct StartupDraftPump {
header: Box<dyn HistoryCell>,
bottom_pane: BottomPane,
events: Pin<Box<dyn Stream<Item = TuiEvent> + Send>>,
app_event_rx: UnboundedReceiver<AppEvent>,
initial_screen: StartupDraftInitialScreen,
pending_paste_newline: Option<(Instant, String)>,
}
impl StartupDraft {
/// Initialize the terminal without showing a composer before an expected session picker.
pub(crate) fn new(initial_screen: StartupDraftInitialScreen) -> io::Result<Self> {
let mut initialized_terminal = tui::init()?;
let terminal_restore_guard = TerminalRestoreGuard::new();
initialized_terminal.terminal.clear()?;
let tui = Tui::new(
initialized_terminal.terminal,
initialized_terminal.enhanced_keys_supported,
initialized_terminal.stderr_guard,
);
let (app_event_tx, app_event_rx) = unbounded_channel();
let bottom_pane = startup_draft_bottom_pane(
AppEventSender::new(app_event_tx),
tui.frame_requester(),
tui.enhanced_keys_supported(),
);
let events = tui.event_stream();
let mut draft = Self {
tui,
terminal_restore_guard,
pump: StartupDraftPump {
header: startup_session_header(/*config*/ None),
bottom_pane,
events,
app_event_rx,
initial_screen,
pending_paste_newline: None,
},
};
if initial_screen == StartupDraftInitialScreen::Composer {
draft.pump.show(&mut draft.tui)?;
}
Ok(draft)
}
/// Accept draft edits on the current task until an existing startup operation completes.
pub(crate) async fn run_until<F>(&mut self, future: F) -> io::Result<F::Output>
where
F: Future,
{
self.pump.run_until(&mut self.tui, future).await
}
/// Capture queued draft edits before lending the terminal to another input owner.
pub(crate) async fn flush_pending_events(&mut self) -> io::Result<()> {
self.pump.flush_pending_events(&mut self.tui).await
}
/// Apply the loaded editing preferences without enabling startup actions or submission.
pub(crate) fn apply_config(&mut self, config: &Config) {
self.pump.apply_config(config);
}
/// Lend the original terminal to an existing interactive startup screen.
pub(crate) fn tui_mut(&mut self) -> &mut Tui {
&mut self.tui
}
/// Transfer terminal ownership while keeping the same draft pump available to later startup.
pub(crate) fn into_parts(self) -> (Tui, TerminalRestoreGuard, StartupDraftPump) {
(self.tui, self.terminal_restore_guard, self.pump)
}
}
impl StartupDraftPump {
/// Refresh the session header and safe editor shortcuts without enabling modal editing.
pub(crate) fn apply_config(&mut self, config: &Config) {
self.header = startup_session_header(Some(config));
self.bottom_pane
.set_disable_paste_burst(config.disable_paste_burst);
self.bottom_pane.request_redraw();
if let Ok(keymap) = RuntimeKeymap::from_config(&config.tui_keymap) {
self.bottom_pane.set_keymap_bindings(&keymap);
}
}
/// Poll one existing startup future alongside the original terminal input stream.
pub(crate) async fn run_until<F>(&mut self, tui: &mut Tui, future: F) -> io::Result<F::Output>
where
F: Future,
{
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, tui.terminal.last_known_screen_size)?;
}
tokio::pin!(future);
loop {
tokio::select! {
output = &mut future => return Ok(output),
event = self.events.next() => {
let Some(event) = event else {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"terminal input stream closed during startup",
));
};
self.handle_event(tui, event)?;
}
}
}
}
/// Preserve pending draft edits while continuing to reject startup actions and submission.
pub(crate) async fn flush_pending_events(&mut self, tui: &mut Tui) -> io::Result<()> {
loop {
for _ in 0..STARTUP_EVENT_BATCH_SIZE {
let Poll::Ready(Some(event)) = std::future::poll_fn(|context| {
Poll::Ready(self.events.as_mut().poll_next(context))
})
.await
else {
self.bottom_pane.flush_composer_paste_burst();
return Ok(());
};
self.handle_event(tui, event)?;
}
tokio::task::yield_now().await;
}
}
/// Resolve an ambiguous paste newline before its provisional input owner disappears.
pub(crate) async fn flush_pending_paste_newline(&mut self, tui: &mut Tui) -> io::Result<()> {
while let Some((started_at, _)) = &self.pending_paste_newline {
let Some(remaining) = STARTUP_PASTE_NEWLINE_TIMEOUT.checked_sub(started_at.elapsed())
else {
self.pending_paste_newline = None;
break;
};
let Ok(Some(event)) = tokio::time::timeout(remaining, self.events.next()).await else {
self.pending_paste_newline = None;
break;
};
self.handle_event(tui, event)?;
}
self.bottom_pane.flush_composer_paste_burst();
Ok(())
}
/// Preserve the editable draft, its cursor, and any pending large-paste placeholders.
pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot {
self.bottom_pane.flush_composer_paste_burst();
self.bottom_pane.composer_draft_snapshot()
}
/// Reveal the editable composer once a requested session picker has finished.
pub(crate) fn show(&mut self, tui: &mut Tui) -> io::Result<()> {
self.initial_screen = StartupDraftInitialScreen::Composer;
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 => {
self.pending_paste_newline = Some((started_at, newlines));
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, screen_size)?;
}
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::SessionPicker
&& !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()));
}
}
handle_startup_draft_key(&mut self.bottom_pane, key).inspect_err(|error| {
if StartupCancelled::matches(error)
&& let Err(clear_error) = tui.terminal.clear()
{
tracing::warn!(
error = %clear_error,
"failed to clear the cancelled startup composer"
);
}
})?;
}
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 => {}
}
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.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);
let desired_height = 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);
if let Some((x, y)) = renderable.cursor_pos(area) {
frame.set_cursor_style(renderable.cursor_style(area));
frame.set_cursor_position((x, y));
}
})
}
}
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(
|| PathBuf::from("loading"),
|config| config.cwd.to_path_buf(),
);
Box::new(
history_cell::SessionHeaderHistoryCell::new_with_style(
"loading".to_string(),
placeholder_style,
/*reasoning_effort*/ None,
/*show_fast_status*/ false,
directory,
CODEX_CLI_VERSION,
)
.with_yolo_mode(config.is_some_and(history_cell::is_yolo_mode)),
)
}
fn startup_draft_renderable<'a>(
header: &'a dyn Renderable,
bottom_pane: &'a BottomPane,
) -> RenderableItem<'a> {
let mut renderable = FlexRenderable::new();
renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header));
renderable.push(
/*flex*/ 0,
bottom_pane
.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0)
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(renderable))
}
fn startup_draft_bottom_pane(
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
enhanced_keys_supported: bool,
) -> BottomPane {
let mut bottom_pane = BottomPane::new_with_composer_config(
BottomPaneParams {
app_event_tx,
frame_requester,
has_input_focus: true,
enhanced_keys_supported,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: None,
},
ChatComposerConfig::plain_text(),
);
bottom_pane.set_context_window_pending(/*pending*/ true);
bottom_pane
}
#[cfg(test)]
#[path = "startup_draft_tests.rs"]
mod tests;
+595
View File
@@ -0,0 +1,595 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
use super::StartupDraftInitialScreen;
use super::StartupDraftPump;
use super::handle_startup_draft_key;
use super::startup_draft_bottom_pane;
use super::startup_draft_renderable;
use super::startup_session_header;
use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::ConfigBuilder;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
use crate::tui::TuiEvent;
fn startup_test_pump<I>(events: I) -> StartupDraftPump
where
I: Iterator<Item = TuiEvent> + Send + 'static,
{
let (tx, rx) = unbounded_channel();
StartupDraftPump {
header: startup_session_header(/*config*/ None),
bottom_pane: startup_draft_bottom_pane(
AppEventSender::new(tx),
FrameRequester::test_dummy(),
/*enhanced_keys_supported*/ false,
),
events: Box::pin(futures::stream::iter(events)),
app_event_rx: rx,
initial_screen: StartupDraftInitialScreen::Composer,
pending_paste_newline: None,
}
}
#[test]
fn startup_draft_renders_full_empty_and_multiline_composer_frames() {
let mut pump = startup_test_pump(std::iter::empty());
let mut snapshots = Vec::new();
for (label, width, text) in [
("empty", 48, ""),
("multiline", 48, "first startup line\nsecond startup line"),
("narrow", 18, "first startup line\nsecond startup line"),
] {
pump.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
renderable.desired_height(width),
);
let mut buffer = Buffer::empty(area);
renderable.render(area, &mut buffer);
let cursor = renderable
.cursor_pos(area)
.expect("keep the editable composer cursor visible below its header");
let frame = (0..area.height)
.map(|row| {
(0..area.width)
.map(|column| buffer[(column, row)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>");
assert!(
cursor.1 >= pump.header.desired_height(width),
"the composer cursor should remain below the startup header"
);
snapshots.push(format!("{label} ({width} columns):\n{frame}"));
}
insta::assert_snapshot!("startup_draft_full_frames", snapshots.join("\n---\n"));
}
#[tokio::test]
async fn startup_draft_hydrates_its_header_without_moving_the_composer() {
let codex_home = tempfile::tempdir().expect("create temporary Codex home");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("build startup configuration");
let mut pump = startup_test_pump(std::iter::empty());
let width = 80;
let initial_height =
startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width);
assert_eq!(
pump.header.raw_lines().last().map(ToString::to_string),
Some("directory: loading".to_string())
);
pump.apply_config(&config);
let expected_directory = format!(
"directory: {}",
crate::history_cell::SessionHeaderHistoryCell::format_directory_inner(
config.cwd.as_path(),
/*max_width*/ None,
)
);
assert_eq!(
pump.header.raw_lines().last().map(ToString::to_string),
Some(expected_directory)
);
assert_eq!(
startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width),
initial_height
);
}
#[test]
fn startup_draft_preserves_literal_text_without_popups_or_startup_actions() {
let mut pump = startup_test_pump(std::iter::empty());
for character in "/quit @src $skill /tmp/image.png".chars() {
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE),
)
.expect("preserve literal startup text");
}
pump.bottom_pane.flush_composer_paste_burst();
let expected = "/quit @src $skill /tmp/image.png";
assert_eq!(pump.bottom_pane.composer_text(), expected);
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat),
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
KeyEvent::new_with_kind(
KeyCode::Char('x'),
KeyModifiers::NONE,
KeyEventKind::Release,
),
] {
handle_startup_draft_key(&mut pump.bottom_pane, key).expect("ignore startup action");
}
assert_eq!(pump.bottom_pane.composer_text(), expected);
assert!(pump.bottom_pane.composer_local_images().is_empty());
assert!(pump.bottom_pane.no_modal_or_popup_active());
assert!(pump.app_event_rx.try_recv().is_err());
}
#[test]
fn startup_draft_preserves_control_cancellation_and_forward_deletion() {
let mut pump = startup_test_pump(std::iter::empty());
for character in "abc".chars() {
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE),
)
.expect("buffer startup text before Ctrl+D");
}
assert!(pump.bottom_pane.is_in_paste_burst());
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
)
.expect("move inside the startup draft");
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
)
.expect("delete the character under the cursor");
assert_eq!(pump.bottom_pane.composer_text(), "ab");
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
handle_startup_draft_key(&mut pump.bottom_pane, ctrl_c)
.expect("clear nonempty startup draft before cancelling");
assert_eq!(pump.bottom_pane.composer_text(), "");
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
)
.expect("hold a startup character before cancellation");
handle_startup_draft_key(&mut pump.bottom_pane, ctrl_c)
.expect("clear the held character instead of cancelling startup");
for key in [
ctrl_c,
KeyEvent::new(KeyCode::Char('\u{3}'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::NONE),
] {
let error = handle_startup_draft_key(&mut pump.bottom_pane, key)
.expect_err("cancel the empty draft using normalized terminal shortcuts");
assert!(super::StartupCancelled::matches(&error));
}
}
#[test]
fn startup_draft_preserves_multiline_editing_without_submitting() {
let mut pump = startup_test_pump(std::iter::empty());
pump.bottom_pane.insert_str("first");
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('\n'), KeyModifiers::NONE),
] {
handle_startup_draft_key(&mut pump.bottom_pane, key).expect("insert a startup newline");
}
pump.bottom_pane.flush_composer_paste_burst();
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.expect("ignore startup submission");
pump.bottom_pane.insert_str("second");
assert_eq!(pump.bottom_pane.composer_text(), "first\n\n\n\nsecond");
assert!(pump.app_event_rx.try_recv().is_err());
}
#[tokio::test]
async fn startup_draft_preserves_non_bracketed_multiline_pastes_without_submitting() {
let events = "first line\n\nsecond line\nthird line"
.chars()
.map(|character| {
TuiEvent::Key(KeyEvent::new(
if character == '\n' {
KeyCode::Enter
} else {
KeyCode::Char(character)
},
KeyModifiers::NONE,
))
});
let mut pump = startup_test_pump(events);
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.flush_pending_events(&mut tui)
.await
.expect("preserve multiline non-bracketed paste");
assert_eq!(
pump.bottom_pane.composer_text(),
"first line\n\nsecond line\nthird line"
);
assert!(pump.app_event_rx.try_recv().is_err());
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.expect("ignore standalone Enter after the paste finishes");
assert_eq!(
pump.bottom_pane.composer_text(),
"first line\n\nsecond line\nthird line"
);
assert!(pump.app_event_rx.try_recv().is_err());
}
#[tokio::test]
async fn startup_draft_preserves_deferred_paste_newlines_across_handoff() {
let (event_tx, event_rx) = unbounded_channel();
let mut pump = startup_test_pump(std::iter::empty());
pump.events = Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
event_rx,
));
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
for character in "first line".chars() {
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)),
)
.expect("buffer the first non-bracketed paste line");
}
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.expect("defer an ambiguous paste newline");
assert!(pump.pending_paste_newline.is_some());
let continuation = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 10)).await;
event_tx
.send(TuiEvent::Key(KeyEvent::new(
KeyCode::Char('s'),
KeyModifiers::NONE,
)))
.expect("continue the paste during handoff");
});
pump.flush_pending_paste_newline(&mut tui)
.await
.expect("finish the deferred paste before startup handoff");
continuation.await.expect("join the paste continuation");
assert_eq!(pump.into_draft().text, "first line\ns");
}
#[tokio::test]
async fn startup_draft_does_not_turn_a_standalone_enter_into_a_newline_at_handoff() {
let (_event_tx, event_rx) = unbounded_channel();
let mut pump = startup_test_pump(std::iter::empty());
pump.events = Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
event_rx,
));
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
for character in "first line".chars() {
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)),
)
.expect("buffer startup typing");
}
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.expect("keep standalone startup Enter provisional");
assert!(pump.pending_paste_newline.is_some());
pump.flush_pending_paste_newline(&mut tui)
.await
.expect("discard standalone Enter after its lookahead expires");
assert_eq!(pump.into_draft().text, "first line");
}
#[test]
fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() {
let mut pump = startup_test_pump(std::iter::empty());
pump.bottom_pane.insert_str("first second");
for (key, expected_cursor) in [
(
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
"first ".len(),
),
(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), 0),
(
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
"first second".len(),
),
] {
handle_startup_draft_key(&mut pump.bottom_pane, key).expect("apply editor movement");
assert_eq!(pump.bottom_pane.composer_cursor(), expected_cursor);
}
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
)
.expect("delete the previous word");
assert_eq!(pump.bottom_pane.composer_text(), "first ");
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.editor.move_line_start = vec![crate::key_hint::ctrl(KeyCode::Char('z'))];
keymap.editor.move_line_end = vec![crate::key_hint::ctrl(KeyCode::Char('v'))];
keymap.editor.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('s'))];
keymap.editor.insert_newline = vec![crate::key_hint::plain(KeyCode::Enter)];
keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('s'))];
pump.bottom_pane.set_keymap_bindings(&keymap);
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
)
.expect("use a configured editor movement");
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('\u{12}'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('\u{13}'), KeyModifiers::NONE),
] {
handle_startup_draft_key(&mut pump.bottom_pane, key)
.expect("ignore image, history, and configured submission actions");
}
keymap.composer.submit = vec![crate::key_hint::plain(KeyCode::Char('s'))];
keymap.composer.queue = vec![crate::key_hint::plain(KeyCode::Char('q'))];
keymap.composer.history_search_previous = vec![crate::key_hint::plain(KeyCode::Char('r'))];
pump.bottom_pane.set_keymap_bindings(&keymap);
for character in ['s', 'q', 'r'] {
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE),
)
.expect("ignore configured plain composer actions");
}
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
assert_eq!(pump.bottom_pane.composer_text(), "first ");
assert!(pump.app_event_rx.try_recv().is_err());
}
#[cfg(target_os = "windows")]
#[test]
fn startup_draft_preserves_windows_altgr_text_input() {
let mut pump = startup_test_pump(std::iter::empty());
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(
KeyCode::Char('@'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
)
.expect("preserve printable AltGr input");
pump.bottom_pane.flush_composer_paste_burst();
assert_eq!(pump.bottom_pane.composer_text(), "@");
}
#[tokio::test]
async fn startup_draft_applies_paste_burst_preferences_without_losing_buffered_input() {
let codex_home = tempfile::tempdir().expect("create temporary Codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("build startup configuration");
let mut pump = startup_test_pump(std::iter::empty());
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
)
.expect("buffer input before startup configuration loads");
assert!(pump.bottom_pane.is_in_paste_burst());
assert_eq!(pump.bottom_pane.composer_text(), "");
config.disable_paste_burst = true;
pump.apply_config(&config);
assert_eq!(pump.bottom_pane.composer_text(), "a");
assert!(!pump.bottom_pane.is_in_paste_burst());
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
)
.expect("insert text immediately when paste detection is disabled");
assert_eq!(pump.bottom_pane.composer_text(), "ab");
assert!(!pump.bottom_pane.is_in_paste_burst());
config.disable_paste_burst = false;
pump.apply_config(&config);
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.expect("restore paste detection after a later configuration change");
assert_eq!(pump.bottom_pane.composer_text(), "ab");
assert!(pump.bottom_pane.is_in_paste_burst());
config.disable_paste_burst = true;
pump.apply_config(&config);
assert_eq!(pump.bottom_pane.composer_text(), "abc");
assert!(!pump.bottom_pane.is_in_paste_burst());
assert!(pump.app_event_rx.try_recv().is_err());
}
#[tokio::test]
async fn startup_draft_applies_editor_keymap_without_enabling_vim() {
let codex_home = tempfile::tempdir().expect("create temporary Codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("build startup configuration");
config.tui_vim_mode_default = true;
config.tui_keymap.editor.move_line_start = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-z".to_string()),
));
let mut pump = startup_test_pump(std::iter::empty());
pump.bottom_pane.insert_str("draft");
pump.apply_config(&config);
assert!(!pump.bottom_pane.composer_is_vim_enabled());
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
)
.expect("insert ordinary text despite the configured Vim mode");
pump.bottom_pane.flush_composer_paste_burst();
assert_eq!(pump.bottom_pane.composer_text(), "draftx");
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
)
.expect("honor a configured safe editor shortcut");
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
}
#[tokio::test]
async fn startup_draft_waits_for_session_picker_before_accepting_input() {
let mut pump = startup_test_pump(
[
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
TuiEvent::Paste("not a draft".to_string()),
TuiEvent::Draw,
]
.into_iter(),
);
pump.initial_screen = StartupDraftInitialScreen::SessionPicker;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.flush_pending_events(&mut tui)
.await
.expect("ignore input before the session picker owns the screen");
assert!(pump.bottom_pane.composer_is_empty());
assert!(tui.terminal.viewport_area.is_empty());
pump.show(&mut tui)
.expect("show the composer after a session is selected");
assert!(!tui.terminal.viewport_area.is_empty());
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
)
.expect("edit the composer after the session picker exits");
pump.bottom_pane.flush_composer_paste_burst();
assert_eq!(pump.bottom_pane.composer_text(), "y");
let ctrl_c = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
pump.handle_event(&mut tui, ctrl_c.clone())
.expect("clear the visible startup draft before cancelling");
assert!(pump.bottom_pane.composer_is_empty());
let error = pump
.handle_event(&mut tui, ctrl_c)
.expect_err("cancel the visible startup composer");
assert!(super::StartupCancelled::matches(&error));
}
#[tokio::test]
async fn startup_draft_allows_cancellation_before_session_picker_appears() {
let mut pump = startup_test_pump(std::iter::once(TuiEvent::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
))));
pump.initial_screen = StartupDraftInitialScreen::SessionPicker;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
let error = pump
.flush_pending_events(&mut tui)
.await
.expect_err("cancel startup before the session picker appears");
assert!(super::StartupCancelled::matches(&error));
}
#[tokio::test]
async fn startup_draft_flushes_large_event_backlogs_without_submitting_or_retaining_app_events() {
let events = (0..=1024).map(|_| TuiEvent::Draw).chain([
TuiEvent::Key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)),
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
TuiEvent::Paste("\u{1b}[31m last\r\nline ".to_string()),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE)),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)),
TuiEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
]);
let mut pump = startup_test_pump(events);
pump.bottom_pane.insert_str("trusted");
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.flush_pending_events(&mut tui)
.await
.expect("flush safe startup input");
assert!(pump.app_event_rx.try_recv().is_err());
let draft = pump.into_draft();
assert_eq!(draft.text, "trusted! last\nline @s");
assert_eq!(draft.cursor, draft.text.len() - 1);
assert!(draft.local_images.is_empty());
}
#[tokio::test]
async fn startup_draft_preserves_large_pastes_without_attaching_images() {
let pasted = "x".repeat(/*n*/ 1_100);
let mut pump = startup_test_pump(std::iter::once(TuiEvent::Paste(pasted.clone())));
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.flush_pending_events(&mut tui)
.await
.expect("preserve large startup paste");
let draft = pump.into_draft();
assert_eq!(draft.pending_pastes, vec![(draft.text.clone(), pasted)]);
assert_eq!(draft.cursor, draft.text.len());
assert!(draft.local_images.is_empty());
}
+538
View File
@@ -0,0 +1,538 @@
//! Orchestrates startup while the provisional composer owns terminal input.
//!
//! Lightweight validation runs before acquiring the terminal. Once the draft is visible, slow
//! configuration and app-server initialization remain responsive to safe local editing.
use super::*;
pub(super) async fn run_main_inner(
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
let strict_config = cli.strict_config;
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
Some(AskForApproval::Never.to_core()),
)
} else {
(
cli.sandbox_mode.map(Into::<SandboxMode>::into),
cli.approval_policy.map(Into::into),
)
};
cli.shared
.take_auto_review_config_overrides(&mut cli.config_overrides);
// Map the legacy --search flag to the canonical web_search mode.
if cli.web_search {
cli.config_overrides
.raw_overrides
.push("web_search=\"live\"".to_string());
}
// When using `--oss`, let the bootstrapper pick the model (defaulting to
// gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
let raw_overrides = cli.config_overrides.raw_overrides.clone();
// `oss` model provider.
let overrides_cli = codex_utils_cli::CliConfigOverrides { raw_overrides };
let cli_kv_overrides = match overrides_cli.parse_overrides() {
// Parse `-c` overrides from the CLI.
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
// we load config.toml here to determine project state.
#[allow(clippy::print_stderr)]
let codex_home = match find_codex_home() {
Ok(codex_home) => codex_home.to_path_buf(),
Err(err) => {
eprintln!("Error finding codex home: {err}");
std::process::exit(1);
}
};
let mut launch_loader_overrides = loader_overrides.clone();
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
launch_loader_overrides.user_config_path = Some(user_config_path);
launch_loader_overrides.user_config_profile = Some(profile_v2.clone());
}
let workload_identity_selected = is_workload_identity_selected();
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
let validation_target = app_server_target_for_launch(
explicit_remote_endpoint.clone(),
/*default_daemon_socket*/ None,
/*can_reuse_implicit_local_daemon*/ false,
workload_identity_selected,
)?;
let validation_environment_manager =
if should_load_configured_environments(&loader_overrides, &validation_target) {
EnvironmentManager::prepare_from_codex_home(&codex_home).await
} else {
EnvironmentManager::prepare_from_env().await
}
.map_err(std::io::Error::other)?;
let validation_cwd = config_cwd_for_app_server_target(
cli.cwd.as_deref(),
&validation_target,
validation_environment_manager.default_environment_is_remote(),
)?;
let mut validation_loader_overrides = launch_loader_overrides.clone();
validation_loader_overrides.ignore_login_requirements =
validation_target.uses_remote_workspace();
let validation_bootstrap = load_bootstrap_config_or_exit(
&codex_home,
validation_cwd.as_ref(),
cli_kv_overrides.clone(),
validation_loader_overrides.clone(),
strict_config,
CloudConfigBundleLoader::default(),
)
.await;
let validation_cloud_config_bundle = if workload_identity_selected {
cloud_config_bundle_for_app_server_target(
&validation_target,
&validation_bootstrap,
&codex_home,
)
.await?
} else {
CloudConfigBundleLoader::default()
};
load_config_or_exit(
cli_kv_overrides.clone(),
ConfigOverrides {
model: cli.model.clone(),
approval_policy,
sandbox_mode,
cwd: validation_cwd.map(AbsolutePathBuf::into_path_buf),
model_provider: cli
.oss
.then(|| {
resolve_oss_provider(
cli.oss_provider.as_deref(),
&validation_bootstrap.config_toml,
)
})
.flatten(),
bypass_hook_trust: cli.bypass_hook_trust.then_some(true),
additional_writable_roots: cli.add_dir.clone(),
..Default::default()
},
validation_loader_overrides,
validation_cloud_config_bundle,
strict_config,
)
.await;
}
let initial_screen = if cli.resume_picker || cli.fork_picker {
startup_draft::StartupDraftInitialScreen::SessionPicker
} else {
startup_draft::StartupDraftInitialScreen::Composer
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;
let reuse_implicit_local_daemon = !workload_identity_selected
&& can_reuse_implicit_local_daemon(
&cli_kv_overrides,
&launch_loader_overrides,
strict_config,
cli.bypass_hook_trust,
);
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
startup_draft
.run_until(maybe_probe_default_daemon_socket(&codex_home))
.await?
} else {
None
};
let app_server_target = app_server_target_for_launch(
explicit_remote_endpoint,
default_daemon,
reuse_implicit_local_daemon,
workload_identity_selected,
)?;
let remote_cwd_override = cli
.cwd
.clone()
.filter(|_| app_server_target.uses_remote_workspace());
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
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)?;
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?;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let cloud_config_bundle = startup_draft
.run_until(cloud_config_bundle_for_app_server_target(
&app_server_target,
&bootstrap_config,
&codex_home,
))
.await??;
let cwd_override = if app_server_target.uses_remote_workspace() {
None
} else {
cwd.clone()
};
let mut manually_selected_oss_provider = None;
let model_provider_override = if cli.oss {
let bootstrap_config_with_cloud_config;
let config_toml_for_oss = if cli.oss_provider.is_none() {
// The first load intentionally skips cloud config so we can read
// auth/base-url settings needed to fetch the bundle. If OSS mode
// needs a default provider from config, reload with the bundle.
bootstrap_config_with_cloud_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,
cloud_config_bundle.clone(),
))
.await?;
&bootstrap_config_with_cloud_config.config_toml
} else {
bootstrap_config_toml
};
let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss);
if let Some(provider) = resolved {
Some(provider)
} else {
let selection = match startup_draft
.run_until(oss_selection::detect_oss_provider())
.await?
{
oss_selection::OssProviderDetection::AutoSelected(selection) => selection,
oss_selection::OssProviderDetection::NeedsSelection {
lmstudio_status,
ollama_status,
} => {
startup_draft.flush_pending_events().await?;
startup_draft
.tui_mut()
.with_restored(|| {
oss_selection::select_oss_provider(lmstudio_status, ollama_status)
})
.await?
}
};
let provider = selection.provider;
if provider == "__CANCELLED__" {
return Err(std::io::Error::other(
"OSS provider selection was cancelled by user",
));
}
if selection.manually_selected {
manually_selected_oss_provider = Some(provider.clone());
}
Some(provider)
}
} else {
None
};
// When using `--oss`, let the bootstrapper pick the model based on selected provider
let model = if let Some(model) = &cli.model {
Some(model.clone())
} else if cli.oss {
// Use the provider from model_provider_override
model_provider_override
.as_ref()
.and_then(|provider_id| get_default_model_for_oss_provider(provider_id))
.map(std::borrow::ToOwned::to_owned)
} else {
None // No model specified, will use the default.
};
let additional_dirs = cli.add_dir.clone();
let overrides = ConfigOverrides {
model,
approval_policy,
sandbox_mode,
cwd: cwd_override,
model_provider: model_provider_override.clone(),
codex_self_exe: arg0_paths.codex_self_exe.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
show_raw_agent_reasoning: cli.oss.then_some(true),
bypass_hook_trust: cli.bypass_hook_trust.then_some(true),
additional_writable_roots: additional_dirs,
..Default::default()
};
let config = startup_draft
.run_until(load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_config_bundle.clone(),
strict_config,
))
.await?;
startup_draft.apply_config(&config);
let cloud_config_bundle = if workload_identity_selected {
cloud_config_bundle
} else {
startup_draft
.run_until(cloud_config_bundle_loader_for_storage(
app_server_target.auth_config_for_cloud_loader(config.auth_config()),
/*enable_codex_api_key_env*/ false,
))
.await??
};
let environment_manager = Arc::new(
prepared_environment_manager
.build(Some(local_runtime_paths), config.http_client_factory())
.map_err(std::io::Error::other)?,
);
remove_legacy_tui_log_file(config.codex_home.as_path());
let otel_originator = originator().value;
let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
codex_app_server_client::build_otel_provider(
&config,
env!("CARGO_PKG_VERSION"),
/*service_name_override*/ None,
/*default_analytics_enabled*/ true,
)
})) {
Ok(Ok(otel)) => otel,
Ok(Err(e)) => {
startup_draft.flush_pending_events().await?;
startup_draft
.tui_mut()
.with_restored(|| async {
#[allow(clippy::print_stderr)]
{
eprintln!("Could not create otel exporter: {e}");
}
})
.await;
None
}
Err(_) => {
#[allow(clippy::print_stderr)]
{
eprintln!("Could not create otel exporter: panicked during initialization");
}
startup_draft.tui_mut().recover_after_caught_panic()?;
None
}
};
if let Some(metrics) = otel.as_ref().and_then(codex_otel::OtelProvider::metrics) {
let _ = codex_otel::record_process_start_once(metrics, otel_originator.as_str());
let telemetry =
codex_rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str());
let _ = codex_state::install_process_db_telemetry(telemetry);
}
let state_db = startup_draft
.run_until(init_state_db_for_app_server_target(
&config,
&app_server_target,
))
.await??;
let config_toml_log_dir_configured = config
.config_layer_stack
.effective_config()
.as_table()
.is_some_and(|table| table.contains_key("log_dir"))
|| config
.config_layer_stack
.requirements_toml()
.log_dir
.is_some();
set_default_client_residency_requirement(config.enforce_residency.value());
if let Some(warning) = add_dir_warning_message(
&cli.add_dir,
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
) {
#[allow(clippy::print_stderr)]
{
restore_terminal_before_fatal_exit();
eprintln!("Error adding directories: {warning}");
std::process::exit(1);
}
}
if !app_server_target.uses_remote_workspace() && !workload_identity_selected {
#[allow(clippy::print_stderr)]
if let Err(err) = startup_draft
.run_until(enforce_login_restrictions(&config.auth_config()))
.await?
{
restore_terminal_before_fatal_exit();
eprintln!("{err}");
std::process::exit(1);
}
}
let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured {
let log_dir = config.log_dir.clone();
std::fs::create_dir_all(&log_dir)?;
let mut log_file_opts = OpenOptions::new();
log_file_opts.create(true).append(true);
// Ensure the file is only readable and writable by the current user.
// Doing the equivalent to `chmod 600` on Windows is quite a bit more
// code and requires the Windows API crates.
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
log_file_opts.mode(0o600);
}
let log_file = log_file_opts.open(log_dir.join(TUI_LOG_FILE_NAME))?;
let (non_blocking, guard) = non_blocking(log_file);
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info")
});
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_target(true)
.with_ansi(false)
.with_span_events(
tracing_subscriber::fmt::format::FmtSpan::NEW
| tracing_subscriber::fmt::format::FmtSpan::CLOSE,
)
.with_filter(env_filter);
(Some(file_layer), Some(guard))
} else {
(None, None)
};
let feedback = codex_feedback::CodexFeedback::new();
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
if cli.oss && model_provider_override.is_some() {
// We're in the oss section, so provider_id should be Some
// Let's handle None case gracefully though just in case
let provider_id = match model_provider_override.as_ref() {
Some(id) => id,
None => {
error!("OSS provider unexpectedly not set when oss flag is used");
return Err(std::io::Error::other(
"OSS provider not set but oss flag was used",
));
}
};
startup_draft.flush_pending_events().await?;
startup_draft
.tui_mut()
.with_restored(|| async {
// Provider setup may print progress or block in an external downloader.
// Restore ordinary signal handling so Ctrl+C can interrupt that process.
crossterm::terminal::disable_raw_mode()?;
ensure_oss_provider_ready(provider_id, &config).await
})
.await?;
}
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
let log_db = state_db.clone().map(log_db::start);
let log_db_layer = log_db
.clone()
.map(|layer| layer.with_filter(log_db::default_filter()));
let _ = tracing_subscriber::registry()
.with(tui_file_layer)
.with(feedback_layer)
.with(feedback_metadata_layer)
.with(log_db_layer)
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();
let app_result = run_ratatui_app(
cli,
arg0_paths,
loader_overrides,
strict_config,
app_server_target,
remote_cwd_override,
config,
manually_selected_oss_provider,
overrides,
cli_kv_overrides,
cloud_config_bundle,
feedback,
log_db,
state_db,
environment_manager,
startup_draft,
)
.await
.map_err(|err| {
err.downcast::<std::io::Error>()
.unwrap_or_else(|err| std::io::Error::other(err.to_string()))
});
if let Some(otel) = otel
&& let Err(err) = otel
.shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT)
.await
{
warn!(error = %err, "failed to finish interactive telemetry shutdown");
}
app_result
}
+9
View File
@@ -693,6 +693,15 @@ impl Tui {
self.event_broker.resume_events(); self.event_broker.resume_events();
} }
/// Reclaim terminal modes and stderr after a panic hook ran inside a recovery boundary.
pub(crate) fn recover_after_caught_panic(&mut self) -> Result<()> {
set_modes()?;
self._stderr_guard.recover_after_caught_panic()?;
self.terminal.invalidate_viewport();
self.frame_requester().schedule_frame();
Ok(())
}
/// Discard buffered typeahead before a startup screen that can confirm an action. /// Discard buffered typeahead before a startup screen that can confirm an action.
/// ///
/// Startup probes can leave parsed key events in crossterm's queue, while later bootstrap /// Startup probes can leave parsed key events in crossterm's queue, while later bootstrap
+28 -2
View File
@@ -53,6 +53,24 @@ impl TerminalStderrGuard {
Ok(Self { active: false }) Ok(Self { active: false })
} }
/// Reclaim stderr after a caught panic temporarily restored terminal ownership.
pub(super) fn recover_after_caught_panic(&mut self) -> io::Result<()> {
if !self.active {
return Ok(());
}
#[cfg(target_os = "macos")]
{
let mut state = lock_state()?;
if !state.owner_active {
suppress_locked(&mut state)?;
state.owner_active = true;
}
}
Ok(())
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn install_suppression() -> io::Result<Self> { fn install_suppression() -> io::Result<Self> {
let mut state = lock_state()?; let mut state = lock_state()?;
@@ -239,7 +257,7 @@ mod tests {
let mut output = tempfile::tempfile()?; let mut output = tempfile::tempfile()?;
let capture = CapturedStderr::start(&output)?; let capture = CapturedStderr::start(&output)?;
let _guard = TerminalStderrGuard::install_suppression()?; let mut guard = TerminalStderrGuard::install_suppression()?;
write_stderr("hidden while active\n")?; write_stderr("hidden while active\n")?;
pause()?; pause()?;
write_stderr("visible while paused\n")?; write_stderr("visible while paused\n")?;
@@ -247,11 +265,19 @@ mod tests {
write_stderr("hidden after resume\n")?; write_stderr("hidden after resume\n")?;
finish()?; finish()?;
write_stderr("visible after finish\n")?; write_stderr("visible after finish\n")?;
guard.recover_after_caught_panic()?;
write_stderr("hidden after caught panic\n")?;
finish()?;
write_stderr("visible after recovered ownership\n")?;
drop(capture); drop(capture);
output.rewind()?; output.rewind()?;
let mut captured = String::new(); let mut captured = String::new();
output.read_to_string(&mut captured)?; output.read_to_string(&mut captured)?;
assert_eq!(captured, "visible while paused\nvisible after finish\n"); assert_eq!(
captured,
"visible while paused\nvisible after finish\nvisible after recovered ownership\n"
);
Ok(()) Ok(())
} }
+82 -12
View File
@@ -12,9 +12,6 @@ use std::time::Instant;
use crossterm::event::Event; use crossterm::event::Event;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use super::InitializedTerminal;
use super::Tui;
const STARTUP_INPUT_CHILD_TEST: &str = "tui::startup_tests::startup_typeahead_pty_child"; const STARTUP_INPUT_CHILD_TEST: &str = "tui::startup_tests::startup_typeahead_pty_child";
#[test] #[test]
@@ -54,6 +51,10 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw
.expect("queue typeahead"); .expect("queue typeahead");
let signals = tempfile::tempdir().expect("create pty synchronization directory"); let signals = tempfile::tempdir().expect("create pty synchronization directory");
let draft_ready = signals.path().join("draft-ready");
let draft_queued = signals.path().join("draft-queued");
let handoff_ready = signals.path().join("handoff-ready");
let handoff_queued = signals.path().join("handoff-queued");
let action_ready = signals.path().join("action-ready"); let action_ready = signals.path().join("action-ready");
let action_queued = signals.path().join("action-queued"); let action_queued = signals.path().join("action-queued");
let fresh_ready = signals.path().join("fresh-ready"); let fresh_ready = signals.path().join("fresh-ready");
@@ -75,8 +76,36 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.expect("spawn startup input pty child"); .expect("spawn startup input pty child");
let mut output = master.try_clone().expect("clone pty output reader");
let output_reader = std::thread::spawn(move || {
let mut captured = Vec::new();
let mut buffer = [0; 4096];
loop {
match output.read(&mut buffer) {
Ok(0) => break,
Ok(read) => captured.extend_from_slice(&buffer[..read]),
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
captured
});
let result = (|| -> Result<(), String> { let result = (|| -> Result<(), String> {
wait_for_file(&draft_ready)?;
master
.write_all(b"abc\r\x1b[DZ")
.map_err(|err| format!("failed to queue editable startup input: {err}"))?;
std::fs::write(&draft_queued, [])
.map_err(|err| format!("failed to signal editable startup input: {err}"))?;
wait_for_file(&handoff_ready)?;
master
.write_all(b"!\x1b[D")
.map_err(|err| format!("failed to queue post-handoff startup input: {err}"))?;
std::fs::write(&handoff_queued, [])
.map_err(|err| format!("failed to signal post-handoff startup input: {err}"))?;
for suffix in ["y", "1"] { for suffix in ["y", "1"] {
let ready = signals.path().join(format!("alt-{suffix}-ready")); let ready = signals.path().join(format!("alt-{suffix}-ready"));
wait_for_file(&ready)?; wait_for_file(&ready)?;
@@ -146,6 +175,7 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw
let _ = child.kill(); let _ = child.kill();
} }
let status = child.wait().expect("wait for startup input pty child"); let status = child.wait().expect("wait for startup input pty child");
let output = output_reader.join().expect("join pty output reader");
let mut stderr = String::new(); let mut stderr = String::new();
child child
.stderr .stderr
@@ -155,10 +185,11 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw
.expect("read child stderr"); .expect("read child stderr");
assert!( assert!(
result.is_ok() && status.success(), result.is_ok() && status.success(),
"startup input pty child failed: {}\n{stderr}", "startup input pty child failed: {}\n{stderr}\n{}",
result result
.err() .err()
.unwrap_or_else(|| "child exited unsuccessfully".to_string()) .unwrap_or_else(|| "child exited unsuccessfully".to_string()),
String::from_utf8_lossy(&output)
); );
} }
@@ -169,11 +200,10 @@ async fn startup_typeahead_pty_child() {
return; return;
}; };
let signals = PathBuf::from(signals); let signals = PathBuf::from(signals);
let InitializedTerminal { let mut startup_draft = crate::startup_draft::StartupDraft::new(
terminal, crate::startup_draft::StartupDraftInitialScreen::Composer,
enhanced_keys_supported, )
stderr_guard, .expect("initialize the real startup composer");
} = super::init().expect("initialize terminal");
let mut typeahead = Vec::new(); let mut typeahead = Vec::new();
for _ in 0..5 { for _ in 0..5 {
@@ -209,7 +239,45 @@ async fn startup_typeahead_pty_child() {
Event::Key(key) if key.code == KeyCode::Up Event::Key(key) if key.code == KeyCode::Up
)); ));
let mut tui = Tui::new(terminal, enhanced_keys_supported, stderr_guard); std::fs::write(signals.join("draft-ready"), []).expect("signal editable startup composer");
let draft_queued = signals.join("draft-queued");
let startup_result = startup_draft
.run_until(async {
while !draft_queued.exists() {
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
}
tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await;
"startup completed"
})
.await
.expect("edit the real composer while startup work is pending");
assert_eq!(startup_result, "startup completed");
let (mut tui, mut terminal_restore_guard, mut startup_draft) = startup_draft.into_parts();
std::fs::write(signals.join("handoff-ready"), []).expect("signal startup terminal handoff");
let handoff_queued = signals.join("handoff-queued");
startup_draft
.run_until(&mut tui, async {
while !handoff_queued.exists() {
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
}
tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await;
})
.await
.expect("keep the real composer editable after terminal ownership transfers");
startup_draft
.flush_pending_events(&mut tui)
.await
.expect("flush real startup terminal input");
tui.pause_events();
let draft = startup_draft.into_draft();
assert_eq!(
draft.text, "abZ!c",
"startup Enter must not submit the draft"
);
assert_eq!(draft.cursor, 3, "startup cursor edits must survive handoff");
assert!(draft.local_images.is_empty());
tui.draw(u16::MAX, |_| {}).expect("draw actionable screen"); tui.draw(u16::MAX, |_| {}).expect("draw actionable screen");
crossterm::event::buffer_input(b"\x1b").expect("buffer a standalone escape"); crossterm::event::buffer_input(b"\x1b").expect("buffer a standalone escape");
let escape_started_at = Instant::now(); let escape_started_at = Instant::now();
@@ -308,7 +376,9 @@ async fn startup_typeahead_pty_child() {
.expect_err("an unresolved escape at a protected boundary must fail closed"); .expect_err("an unresolved escape at a protected boundary must fail closed");
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
super::restore_after_exit().expect("restore terminal"); terminal_restore_guard
.restore()
.expect("restore terminal after startup draft handoff");
} }
fn wait_for_file(path: &Path) -> Result<(), String> { fn wait_for_file(path: &Path) -> Result<(), String> {
+2 -1
View File
@@ -16,7 +16,8 @@ use anyhow::bail;
use anyhow::ensure; use anyhow::ensure;
use tempfile::TempDir; use tempfile::TempDir;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 15); // Full startup continues after the composer first appears and can be slower under Rosetta in CI.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
const FOCUS_INPUT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5); const FOCUS_INPUT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
const FOCUS_PROBE_INPUT: &str = "focus-palette-24527"; const FOCUS_PROBE_INPUT: &str = "focus-palette-24527";