mirror of
https://github.com/openai/codex.git
synced 2026-11-05 00:27:29 +00:00
Keep the composer editable during TUI startup (#38642)
## Why Configuration and app-server initialization can take time before the main TUI is ready, leaving users unable to begin drafting a prompt. ## What changed - Show a provisional composer while startup work runs and carry its text, cursor position, paste state, and attachments into the initialized chat. - Limit the provisional composer to safe editing and cancellation, and quarantine input around session pickers, approvals, and other actionable startup screens. - Preserve configuration validation before terminal checks and recover terminal state cleanly from startup failures and caught panics. ## Testing - Cover startup editing, multiline and large pastes, keymaps, cancellation, session-picker handoff, approval boundaries, and draft restoration. - Verify non-interactive launches report configuration errors before terminal errors. GitOrigin-RevId: fe04a85cfbbcff21ff87fa81fd17474827858575
This commit is contained in:
1 parent
58d2daba45
commit
a0bed4be21
26 files changed
+4348
-1151
No files matched your search
@@ -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
@@ -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(¬ification.request_id)
|
.resolve_notification(¬ification.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(_) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ¶ms.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(¶ms.thread_id)
|
||||||
|
.unwrap_or_else(|_| self.chat_widget.thread_id().unwrap_or_default());
|
||||||
|
AppLinkViewParams::from_url_app_server_request(
|
||||||
|
thread_id,
|
||||||
|
¶ms.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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()?;
|
||||||
|
|||||||
+44
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user