mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Add host cycling and saved defaults for new TUI conversations (#59007)
## What changed - With multi-backend support enabled, show the current host before the first submission and use `leader b` to cycle through available connections. Expose the shortcut as `tui.keymap.global.change_host`. - Preserve the conversation name and composer draft, including cursor position, attachments, pending pastes, and literal versus shell-command interpretation of `!`. - Save explicit host choices in user-only `tui.default_hosts`, scoped to the local project and user identity. Use the saved host for new conversations, falling back to the first available connection when it is unavailable. - Prepare the destination and check directory trust before replacing the current conversation. Block switching during voice calls and active turns, and remove replaced blank conversations from the session overview. ## Testing Add coverage for shortcut visibility and remapping, draft and name preservation, project/user preference isolation, unavailable-host fallback, destination trust and startup failures, and voice-call guards. GitOrigin-RevId: def7b088951336bec8fe3962954fba9b861f91c6
This commit is contained in:
committed by
copyberry
parent
857415f7f2
commit
26f73fba8f
@@ -135,6 +135,8 @@ impl KeybindingsSpec {
|
||||
pub struct TuiGlobalKeymap {
|
||||
/// Prefix for persistent, context-aware shortcut menus.
|
||||
pub leader: Option<KeybindingsSpec>,
|
||||
/// Change where a new conversation runs.
|
||||
pub change_host: Option<KeybindingsSpec>,
|
||||
/// Open the shared agent-session overview.
|
||||
pub open_agents: Option<KeybindingsSpec>,
|
||||
/// Open the transcript overlay.
|
||||
|
||||
@@ -872,6 +872,11 @@ impl QuickDial {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct Tui {
|
||||
/// Preferred hosts for new conversations, indexed by project and local user identity.
|
||||
/// Read from the user configuration only; values are never endpoints or credentials.
|
||||
#[serde(default)]
|
||||
pub default_hosts: BTreeMap<String, BTreeMap<String, String>>,
|
||||
|
||||
#[serde(default, flatten)]
|
||||
pub notification_settings: TuiNotificationSettings,
|
||||
|
||||
|
||||
@@ -5146,6 +5146,17 @@
|
||||
"description": "Deselect read-only transcript or footer text after Copy on select confirms delivery. Editable composer selections remain visible. Defaults to `false` and has no effect when Copy on select is off.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_hosts": {
|
||||
"additionalProperties": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Preferred hosts for new conversations, indexed by project and local user identity. Read from the user configuration only; values are never endpoints or credentials.",
|
||||
"type": "object"
|
||||
},
|
||||
"disable_paste_burst": {
|
||||
"description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts. Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.",
|
||||
"type": "boolean"
|
||||
@@ -5242,6 +5253,7 @@
|
||||
"yank": null
|
||||
},
|
||||
"global": {
|
||||
"change_host": null,
|
||||
"clear_terminal": null,
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
@@ -6035,6 +6047,14 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Global keybindings. These are used when a context does not define an override.",
|
||||
"properties": {
|
||||
"change_host": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Change where a new conversation runs."
|
||||
},
|
||||
"clear_terminal": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6275,6 +6295,7 @@
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"change_host": null,
|
||||
"clear_terminal": null,
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
|
||||
@@ -1393,6 +1393,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
assert_eq!(
|
||||
cfg.tui.expect("tui config should deserialize"),
|
||||
Tui {
|
||||
default_hosts: Default::default(),
|
||||
notification_settings: TuiNotificationSettings::default(),
|
||||
animations: true,
|
||||
screen_reader_detection_done: None,
|
||||
@@ -4754,6 +4755,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
assert_eq!(
|
||||
tui,
|
||||
Tui {
|
||||
default_hosts: Default::default(),
|
||||
notification_settings: TuiNotificationSettings::default(),
|
||||
animations: true,
|
||||
screen_reader_detection_done: None,
|
||||
|
||||
@@ -216,7 +216,7 @@ pub(crate) mod app_server_requests;
|
||||
mod app_server_thread_ownership;
|
||||
mod backend_banner_fallback;
|
||||
mod backend_events;
|
||||
mod backend_selection;
|
||||
pub(crate) mod backend_selection;
|
||||
pub(crate) mod backend_sessions;
|
||||
mod background_requests;
|
||||
mod composer_hints;
|
||||
@@ -549,6 +549,7 @@ pub(crate) struct App {
|
||||
/// Legacy bootstrap and server-setting inputs; local preferences live in `local_settings`.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) local_settings: crate::local_settings::LocalSettings,
|
||||
backend_preference_context: backend_selection::BackendPreferenceContext,
|
||||
launch_cwd: PathBuf,
|
||||
/// Resume anchor selected by `/cd`; ordinary resumes retain the immutable launch cwd.
|
||||
runtime_working_directory_override: Option<PathBuf>,
|
||||
|
||||
@@ -1,10 +1,352 @@
|
||||
//! Prepare saved conversations through their owner without mutating the UI on failure.
|
||||
//! Prepare conversations through their owning backend before replacing the UI.
|
||||
|
||||
use super::backend_sessions::BackendSessions;
|
||||
use super::backend_sessions::SessionSlot;
|
||||
use super::resume_preparation::ResumeSource;
|
||||
use super::session_lifecycle::PreparedFreshSession;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ThreadItemPayload;
|
||||
|
||||
pub(super) enum NewBackendSession {
|
||||
Fresh { name: Option<String> },
|
||||
CarryDraft,
|
||||
}
|
||||
|
||||
/// Host inputs for the new-thread preference. A cloud thread's cwd and credentials
|
||||
/// belong to the executor and must never determine where this preference is saved.
|
||||
pub(crate) struct BackendPreferenceContext {
|
||||
pub(crate) local_cwd: Option<PathBuf>,
|
||||
auth: codex_login::AuthConfig,
|
||||
project_root_markers: Vec<String>,
|
||||
}
|
||||
|
||||
impl BackendPreferenceContext {
|
||||
pub(crate) fn new(config: &Config, local_cwd: Option<PathBuf>) -> Self {
|
||||
let project_root_markers = config
|
||||
.config_layer_stack
|
||||
.layers_high_to_low()
|
||||
.filter(|layer| !matches!(layer.name, codex_config::ConfigLayerSource::Project { .. }))
|
||||
.find_map(|layer| {
|
||||
codex_config::project_root_markers_from_config(&layer.config)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or_else(codex_config::default_project_root_markers);
|
||||
Self {
|
||||
local_cwd,
|
||||
auth: config.auth_config(),
|
||||
project_root_markers,
|
||||
}
|
||||
}
|
||||
|
||||
async fn scope(&self) -> Option<(String, String)> {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(self.local_cwd.as_deref()?).ok()?;
|
||||
let project = codex_config::loader::find_project_root(
|
||||
codex_exec_server::LOCAL_FS.as_ref(),
|
||||
&cwd,
|
||||
&self.project_root_markers,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let auth = self
|
||||
.auth
|
||||
.load_auth(/*enable_codex_api_key_env*/ false)
|
||||
.await
|
||||
.ok()?;
|
||||
let user = if let Some(id) = auth
|
||||
.as_ref()
|
||||
.and_then(codex_login::CodexAuth::get_chatgpt_user_id)
|
||||
{
|
||||
format!("chatgpt:{id}")
|
||||
} else if auth
|
||||
.as_ref()
|
||||
.is_some_and(codex_login::CodexAuth::is_chatgpt_auth)
|
||||
{
|
||||
return None;
|
||||
} else {
|
||||
// A local profile without an account is already private to its CODEX_HOME.
|
||||
"local".to_string()
|
||||
};
|
||||
Some((
|
||||
codex_config::loader::project_trust_key(project.as_path()),
|
||||
user,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) async fn new_thread_host_preference(&self) -> Option<&str> {
|
||||
let defaults = &self.local_settings.tui.default_hosts;
|
||||
if defaults.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (project, user) = self.backend_preference_context.scope().await?;
|
||||
defaults.get(&project)?.get(&user).map(String::as_str)
|
||||
}
|
||||
|
||||
pub(super) async fn other_new_session_backend(
|
||||
&self,
|
||||
backends: &BackendSessions,
|
||||
) -> Option<crate::backend_id::BackendId> {
|
||||
backends
|
||||
.preferred_listening_backend(self.new_thread_host_preference().await)
|
||||
.filter(|owner| *owner != backends.displayed_owner())
|
||||
}
|
||||
|
||||
pub(super) fn can_cycle_conversation_backend(&self) -> bool {
|
||||
self.voice_owner_thread_id().is_none()
|
||||
&& self.chat_widget.can_choose_new_backend()
|
||||
&& self
|
||||
.current_displayed_thread_id()
|
||||
.and_then(|id| self.thread_event_channels.get(&id))
|
||||
.and_then(|channel| channel.store.try_lock().ok())
|
||||
.is_some_and(|store| store.session.is_some() && store.latest_turn_id.is_none())
|
||||
}
|
||||
|
||||
pub(super) async fn start_new_session_on_other_backend(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
backends: &mut BackendSessions,
|
||||
owner: crate::backend_id::BackendId,
|
||||
session: NewBackendSession,
|
||||
) -> Result<(), String> {
|
||||
let slot = backends
|
||||
.listening_slot(owner)
|
||||
.ok_or("That connection is unavailable. Choose another and try again.")?;
|
||||
if self.windows_sandbox_blocks_thread_switch()
|
||||
|| self.reject_pending_permission_root_switch()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if self.voice_owner_thread_id().is_some() {
|
||||
return Err("End the voice call before switching.".into());
|
||||
}
|
||||
let displayed_thread = self.current_displayed_thread_id();
|
||||
let outgoing_slot = backends.displayed;
|
||||
if let Some(thread_id) = displayed_thread
|
||||
&& self.active_turn_id_for_thread(thread_id).await.is_some()
|
||||
{
|
||||
return Err("Wait for the current turn to finish before switching.".into());
|
||||
}
|
||||
let mut settings = backends
|
||||
.settings
|
||||
.get(&owner)
|
||||
.cloned()
|
||||
.ok_or("Connection settings are unavailable.")?;
|
||||
let preference = if matches!(session, NewBackendSession::CarryDraft)
|
||||
&& let Some(key) = backends.preference_key(slot)
|
||||
{
|
||||
self.backend_preference_context
|
||||
.scope()
|
||||
.await
|
||||
.map(|(project, user)| (project, user, key.to_owned()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let user_config_path = self.local_settings.user_config_path.clone();
|
||||
let (name, draft) = match session {
|
||||
NewBackendSession::Fresh { name } => (name, None),
|
||||
NewBackendSession::CarryDraft => (
|
||||
self.chat_widget.thread_name(),
|
||||
Some(self.chat_widget.composer_draft_snapshot()),
|
||||
),
|
||||
};
|
||||
let destination = backends.session_at_mut(slot);
|
||||
let bootstrap = destination
|
||||
.bootstrap_for_new_thread(&settings.config)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to prepare the new conversation: {error}"))?;
|
||||
let destination_cwd = settings.config.cwd.to_path_buf();
|
||||
if let Err(control) = self
|
||||
.confirm_directory_trust_for_target(
|
||||
tui,
|
||||
destination,
|
||||
&settings.target,
|
||||
&settings.local_settings,
|
||||
&mut settings.config,
|
||||
&destination_cwd,
|
||||
crate::onboarding::DirectoryTrustOptions {
|
||||
cancel: displayed_thread
|
||||
.map(|_| crate::onboarding::TrustCancelAction::CurrentTask),
|
||||
..Default::default()
|
||||
},
|
||||
/*startup_draft*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if matches!(control, AppRunControl::Exit(_)) {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (config, local_settings) = self
|
||||
.load_new_session_config_from(destination, &settings, destination_cwd)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read new session defaults: {error}"))?;
|
||||
let started = destination
|
||||
.start_thread_with_session_start_source(
|
||||
&local_settings,
|
||||
&config,
|
||||
/*session_start_source*/ None,
|
||||
/*remote_cwd_override*/ None,
|
||||
/*selected_profile*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to start the new conversation: {error}"))?;
|
||||
// Clear before switching so a terminal error leaves the outgoing conversation usable.
|
||||
tui.clear_for_thread_switch()
|
||||
.map_err(|error| format!("Failed to open the new conversation: {error}"))?;
|
||||
let destination_thread = started.session.thread_id;
|
||||
self.switch_backend_context(
|
||||
backends,
|
||||
slot,
|
||||
started.session.thread_id,
|
||||
settings.target,
|
||||
config.cwd.to_path_buf(),
|
||||
bootstrap,
|
||||
)
|
||||
.await;
|
||||
let destination = backends.session_at_mut(slot);
|
||||
self.adopt_fresh_session(
|
||||
tui,
|
||||
destination,
|
||||
PreparedFreshSession {
|
||||
started,
|
||||
config,
|
||||
local_settings,
|
||||
},
|
||||
ThreadAttachPresentation::FreshAfterClear,
|
||||
/*initial_user_message*/ None,
|
||||
name,
|
||||
)
|
||||
.await;
|
||||
backends.displayed = slot;
|
||||
if !backends.session_at_mut(slot).uses_remote_workspace()
|
||||
&& self.current_displayed_thread_id() == Some(destination_thread)
|
||||
{
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
self.review_folder_hooks_after_attach(backends.session_at_mut(slot), cwd)
|
||||
.await;
|
||||
}
|
||||
tui.set_notification_settings(
|
||||
self.local_settings.tui.notification_settings.method,
|
||||
self.local_settings.tui.notification_settings.condition,
|
||||
);
|
||||
if let Some(draft) = draft {
|
||||
self.chat_widget.restore_startup_draft(draft);
|
||||
if let Some(thread_id) = displayed_thread
|
||||
&& self.current_displayed_thread_id() == Some(destination_thread)
|
||||
&& self
|
||||
.agents_overview
|
||||
.blank_sessions
|
||||
.remove(&thread_id)
|
||||
.is_some()
|
||||
{
|
||||
self.agents_overview.input_states.remove(&thread_id);
|
||||
self.remove_agents_overview_thread(thread_id);
|
||||
if let Err(error) = backends
|
||||
.session_at_mut(outgoing_slot)
|
||||
.thread_unsubscribe(thread_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error, "failed to unsubscribe replaced blank thread {thread_id}");
|
||||
}
|
||||
self.discard_thread_local_state(thread_id).await;
|
||||
}
|
||||
}
|
||||
if self.current_displayed_thread_id() == Some(destination_thread)
|
||||
&& let Some((project, user, key)) = preference
|
||||
{
|
||||
let result = ConfigEditsBuilder::for_config_path(user_config_path.as_path())
|
||||
.with_edits([crate::legacy_core::config::edit::ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"tui".into(),
|
||||
"default_hosts".into(),
|
||||
project.clone(),
|
||||
user.clone(),
|
||||
],
|
||||
value: toml_edit::value(key.clone()),
|
||||
}])
|
||||
.apply()
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
for settings in std::iter::once(&mut self.local_settings).chain(
|
||||
backends
|
||||
.settings
|
||||
.values_mut()
|
||||
.map(|settings| &mut settings.local_settings),
|
||||
) {
|
||||
settings
|
||||
.tui
|
||||
.default_hosts
|
||||
.entry(project.clone())
|
||||
.or_default()
|
||||
.insert(user.clone(), key.clone());
|
||||
}
|
||||
}
|
||||
Err(error) => self.chat_widget.add_error_message(format!(
|
||||
"Switched, but could not save the default for this project: {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Both create and resume call this after the destination thread is ready.
|
||||
async fn switch_backend_context(
|
||||
&mut self,
|
||||
backends: &mut BackendSessions,
|
||||
destination_slot: SessionSlot,
|
||||
thread_id: ThreadId,
|
||||
target: AppServerTarget,
|
||||
cwd: PathBuf,
|
||||
bootstrap: AppServerBootstrap,
|
||||
) {
|
||||
let outgoing = backends.displayed_owner();
|
||||
self.capture_session_inputs().await;
|
||||
for channel in self.thread_event_channels.values_mut() {
|
||||
channel.backend_id.get_or_insert(outgoing);
|
||||
}
|
||||
backends.settings.insert(outgoing, self.backend_settings());
|
||||
self.detach_current_thread_for_navigation(
|
||||
backends.session_at_mut(backends.displayed),
|
||||
Some(thread_id),
|
||||
)
|
||||
.await;
|
||||
let destination = backends.session_at_mut(destination_slot);
|
||||
self.store_active_thread_receiver().await;
|
||||
self.clear_active_thread().await;
|
||||
self.agents_overview.pending_usage = None;
|
||||
self.agents_overview.usage_disabled = false;
|
||||
self.app_event_tx = AppEventSender::new(self.app_event_tx.app_event_tx.clone());
|
||||
self.app_server_target = target;
|
||||
self.reconnect = Default::default();
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.connection_notice = None;
|
||||
}
|
||||
self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1);
|
||||
self.rate_limit_refresh_state.invalidate_recovery();
|
||||
self.account_email_request_id = None;
|
||||
self.file_search.on_user_query(String::new());
|
||||
self.bind_backend_workspace(destination, cwd);
|
||||
self.model_catalog = Arc::new(
|
||||
ModelCatalog::new(bootstrap.available_models)
|
||||
.with_collaboration_modes(bootstrap.collaboration_modes),
|
||||
);
|
||||
self.feedback_audience = bootstrap.feedback_audience;
|
||||
self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth;
|
||||
self.chat_widget.invalidate_security_setup();
|
||||
self.chat_widget.update_account_state(
|
||||
bootstrap.status_account_display,
|
||||
bootstrap.plan_type,
|
||||
bootstrap.has_chatgpt_account,
|
||||
matches!(bootstrap.auth_mode, Some(TelemetryAuthMode::Chatgpt)),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn resume_backend_session<'a>(
|
||||
&'a mut self,
|
||||
tui: &'a mut tui::Tui,
|
||||
@@ -33,20 +375,19 @@ impl App {
|
||||
&& self.active_turn_id_for_thread(thread_id).await.is_some()
|
||||
{
|
||||
self.add_session_picker_error(
|
||||
"Wait for the current turn to finish before switching backends.".into(),
|
||||
"Wait for the current turn to finish before switching.".into(),
|
||||
);
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
let Some(settings) = backends.settings.get(&owner).cloned() else {
|
||||
self.add_session_picker_error(
|
||||
"This backend has no connection settings. Open the session picker again."
|
||||
.into(),
|
||||
"Connection settings are unavailable. Open the session picker again.".into(),
|
||||
);
|
||||
return Ok(AppRunControl::Continue);
|
||||
};
|
||||
let Some(destination_slot) = backends.listening_slot(owner) else {
|
||||
self.add_session_picker_error(
|
||||
"This session's backend is unavailable. Open the session picker again.".into(),
|
||||
"The connection for this conversation is unavailable. Open the session picker again.".into(),
|
||||
);
|
||||
return Ok(AppRunControl::Continue);
|
||||
};
|
||||
@@ -54,7 +395,7 @@ impl App {
|
||||
let bootstrap = match destination.bootstrap(&settings.config).await {
|
||||
Ok(bootstrap) => bootstrap,
|
||||
Err(error) => {
|
||||
let message = format!("Failed to prepare session backend: {error}");
|
||||
let message = format!("Failed to prepare the conversation: {error}");
|
||||
self.add_session_picker_error(message);
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
@@ -66,46 +407,16 @@ impl App {
|
||||
Ok(prepared) => prepared,
|
||||
Err(control) => return Ok(control),
|
||||
};
|
||||
self.capture_session_inputs().await;
|
||||
for channel in self.thread_event_channels.values_mut() {
|
||||
channel.backend_id.get_or_insert(outgoing);
|
||||
}
|
||||
backends.settings.insert(outgoing, self.backend_settings());
|
||||
self.detach_current_thread_for_navigation(
|
||||
backends.session_at_mut(displayed_session),
|
||||
Some(target.thread_id),
|
||||
self.switch_backend_context(
|
||||
backends,
|
||||
destination_slot,
|
||||
target.thread_id,
|
||||
settings.target,
|
||||
prepared.config.cwd.to_path_buf(),
|
||||
bootstrap,
|
||||
)
|
||||
.await;
|
||||
let destination = backends.session_at_mut(destination_slot);
|
||||
self.store_active_thread_receiver().await;
|
||||
self.clear_active_thread().await;
|
||||
self.agents_overview.pending_usage = None;
|
||||
self.agents_overview.usage_disabled = false;
|
||||
self.app_event_tx = AppEventSender::new(self.app_event_tx.app_event_tx.clone());
|
||||
self.app_server_target = settings.target;
|
||||
self.reconnect = Default::default();
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.connection_notice = None;
|
||||
}
|
||||
self.rate_limit_hard_stop_generation =
|
||||
self.rate_limit_hard_stop_generation.wrapping_add(1);
|
||||
self.rate_limit_refresh_state.invalidate_recovery();
|
||||
self.account_email_request_id = None;
|
||||
self.file_search.on_user_query(String::new());
|
||||
self.bind_backend_workspace(destination, prepared.config.cwd.to_path_buf());
|
||||
self.model_catalog = Arc::new(
|
||||
ModelCatalog::new(bootstrap.available_models)
|
||||
.with_collaboration_modes(bootstrap.collaboration_modes),
|
||||
);
|
||||
self.feedback_audience = bootstrap.feedback_audience;
|
||||
self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth;
|
||||
self.chat_widget.invalidate_security_setup();
|
||||
self.chat_widget.update_account_state(
|
||||
bootstrap.status_account_display,
|
||||
bootstrap.plan_type,
|
||||
bootstrap.has_chatgpt_account,
|
||||
matches!(bootstrap.auth_mode, Some(TelemetryAuthMode::Chatgpt)),
|
||||
);
|
||||
let thread_id = target.thread_id;
|
||||
if let Some(input) = self.agents_overview.input_states.get_mut(&thread_id) {
|
||||
input.provider_error = None;
|
||||
|
||||
@@ -45,6 +45,8 @@ impl InitialBackendSessions {
|
||||
}
|
||||
|
||||
struct SessionEntry {
|
||||
// Stable, nonsecret name supplied by the registrar for this logical app-server.
|
||||
preference_key: Option<String>,
|
||||
session: AppServerSession,
|
||||
listening: bool,
|
||||
pending_requests: VecDeque<AppServerSessionEvent>,
|
||||
@@ -84,6 +86,7 @@ impl BackendSessions {
|
||||
pub(super) fn insert(&mut self, session: AppServerSession) {
|
||||
assert!(self.session(session.backend_id()).is_none());
|
||||
self.entries.push(SessionEntry {
|
||||
preference_key: None,
|
||||
session,
|
||||
listening: true,
|
||||
pending_requests: VecDeque::new(),
|
||||
@@ -118,25 +121,51 @@ impl BackendSessions {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "used by the next stack stage")]
|
||||
pub(super) fn displayed_name(&self) -> Option<String> {
|
||||
let slot = self.displayed.0;
|
||||
self.entries[slot].listening.then(|| match slot {
|
||||
0 => "Local".to_string(),
|
||||
index => format!("Backend {}", index + 1),
|
||||
index => format!("Connection {}", index + 1),
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "used by the next stack stage")]
|
||||
pub(super) fn first_listening_backend(&self) -> Option<BackendId> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|entry| entry.listening)
|
||||
pub(super) fn set_preference_key(&mut self, owner: BackendId, key: &str) {
|
||||
let slot = self.slot(owner).expect("registered app-server");
|
||||
assert!(
|
||||
!key.is_empty()
|
||||
&& key
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
|
||||
);
|
||||
assert!(
|
||||
self.entries
|
||||
.iter()
|
||||
.all(|entry| entry.preference_key.as_deref() != Some(key))
|
||||
);
|
||||
assert!(self.preference_key(slot).is_none());
|
||||
self.entries[slot.0].preference_key = Some(key.to_owned());
|
||||
}
|
||||
|
||||
pub(super) fn preference_key(&self, slot: SessionSlot) -> Option<&str> {
|
||||
self.entries[slot.0].preference_key.as_deref()
|
||||
}
|
||||
|
||||
pub(super) fn preferred_listening_backend(
|
||||
&self,
|
||||
preference: Option<&str>,
|
||||
) -> Option<BackendId> {
|
||||
let mut listening = self.entries.iter().filter(|entry| entry.listening);
|
||||
let preferred = preference.and_then(|key| {
|
||||
listening
|
||||
.clone()
|
||||
.find(|entry| entry.preference_key.as_deref() == Some(key))
|
||||
});
|
||||
preferred
|
||||
.or_else(|| listening.next())
|
||||
.map(|entry| entry.session.backend_id())
|
||||
}
|
||||
|
||||
/// Cycle through listening connections in registration order, excluding the current one.
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "used by the next stack stage"))]
|
||||
pub(super) fn next_listening_backend(&self, current: BackendId) -> Option<BackendId> {
|
||||
let index = self.slot(current)?.0;
|
||||
self.entries[index + 1..]
|
||||
|
||||
@@ -212,7 +212,15 @@ impl App {
|
||||
}
|
||||
AppEvent::PluginMentionsLoaded { ref cwd, .. }
|
||||
if cwds_differ(cwd, self.config.cwd.as_path()) => {}
|
||||
AppEvent::NewSession { name } => {
|
||||
AppEvent::ChooseNewSessionCheckout { name }
|
||||
if self.chat_widget.managed_worktree_available() =>
|
||||
{
|
||||
self.chat_widget
|
||||
.open_session_checkout_picker(crate::app_event::ManagedWorktreeMode::New, name);
|
||||
}
|
||||
AppEvent::ChooseNewSessionCheckout { name }
|
||||
| AppEvent::NewSession { name }
|
||||
| AppEvent::NewSessionInCurrentWorktree { name } => {
|
||||
self.start_fresh_session(
|
||||
tui, app_server, /*session_start_source*/ None,
|
||||
/*initial_user_message*/ None, name,
|
||||
@@ -599,7 +607,7 @@ impl App {
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
AppEvent::ResumeBackendSession(_) => {
|
||||
AppEvent::ResumeBackendSession(_) | AppEvent::CycleNewConversationBackend => {
|
||||
unreachable!("backend selection is handled by the session manager")
|
||||
}
|
||||
AppEvent::ArchiveCurrentThread => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! Build the replacement configuration without replacing the active task's settings.
|
||||
|
||||
use super::resume_preparation::BackendSettings;
|
||||
use super::*;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
@@ -64,7 +65,18 @@ impl App {
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
) -> Result<(Config, crate::local_settings::LocalSettings)> {
|
||||
let settings = self.backend_settings();
|
||||
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
|
||||
self.load_new_session_config_from(app_server, &settings, cwd)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn load_new_session_config_from(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
settings: &BackendSettings,
|
||||
cwd: PathBuf,
|
||||
) -> Result<(Config, crate::local_settings::LocalSettings)> {
|
||||
let defaults_cwd = match app_server.thread_params_mode() {
|
||||
crate::app_server_session::ThreadParamsMode::Embedded => cwd.as_path(),
|
||||
crate::app_server_session::ThreadParamsMode::Remote
|
||||
@@ -78,14 +90,17 @@ impl App {
|
||||
)
|
||||
.await?;
|
||||
// Stage local preferences and permission carryover without changing the active task.
|
||||
let (mut config, local_settings) = match self.rebuild_config_for_cwd(cwd).await {
|
||||
let (mut config, local_settings) = match self
|
||||
.rebuild_config_for_cwd_from(&settings.config, cwd)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
let settings = self.local_settings.reloaded(&config);
|
||||
(config, settings)
|
||||
let local_settings = settings.local_settings.reloaded(&config);
|
||||
(config, local_settings)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "failed to refresh local settings before a new thread");
|
||||
(self.config.clone(), self.local_settings.clone())
|
||||
(settings.config.clone(), settings.local_settings.clone())
|
||||
}
|
||||
};
|
||||
if let Some(defaults) = defaults.as_ref() {
|
||||
@@ -97,7 +112,11 @@ impl App {
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly)?;
|
||||
self.apply_runtime_policy_overrides_with_reviewer(
|
||||
&mut config,
|
||||
RuntimePolicyOverrideScope::ExplicitOnly,
|
||||
settings.config.approvals_reviewer,
|
||||
)?;
|
||||
if let Some(defaults) = defaults.as_ref() {
|
||||
overlay_new_session_defaults(
|
||||
&mut config,
|
||||
@@ -106,6 +125,12 @@ impl App {
|
||||
&self.harness_overrides,
|
||||
);
|
||||
}
|
||||
apply_managed_new_thread_defaults(
|
||||
&mut config,
|
||||
app_server.managed_new_thread_defaults(),
|
||||
&self.cli_kv_overrides,
|
||||
&self.harness_overrides,
|
||||
);
|
||||
Ok((config, local_settings))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ impl App {
|
||||
clippy::too_many_arguments,
|
||||
reason = "The destination target is explicit alongside the existing trust interaction inputs."
|
||||
)]
|
||||
async fn confirm_directory_trust_for_target(
|
||||
pub(super) async fn confirm_directory_trust_for_target(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
|
||||
@@ -20,6 +20,8 @@ use std::collections::HashSet;
|
||||
pub(super) enum ThreadAttachPresentation {
|
||||
/// A primary thread created by thread/start, without inherited history.
|
||||
Fresh,
|
||||
/// A fresh thread whose terminal clear succeeded before its backend was adopted.
|
||||
FreshAfterClear,
|
||||
/// A fresh thread whose startup composer is already visible.
|
||||
FreshWithDraft,
|
||||
SessionLineage,
|
||||
@@ -1107,7 +1109,7 @@ impl App {
|
||||
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
||||
// history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it
|
||||
// until the new session is configured and any replayed turns have been rendered.
|
||||
let (mut config, local_settings) = match self.load_new_session_config(app_server).await {
|
||||
let (config, local_settings) = match self.load_new_session_config(app_server).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
if let Some(message) = initial_user_message {
|
||||
@@ -1119,12 +1121,6 @@ impl App {
|
||||
return;
|
||||
}
|
||||
};
|
||||
apply_managed_new_thread_defaults(
|
||||
&mut config,
|
||||
app_server.managed_new_thread_defaults(),
|
||||
&self.cli_kv_overrides,
|
||||
&self.harness_overrides,
|
||||
);
|
||||
match app_server
|
||||
.start_thread_with_session_start_source(
|
||||
&self.local_settings,
|
||||
@@ -1251,13 +1247,19 @@ impl App {
|
||||
self.active_thread_rx = Some(receiver);
|
||||
}
|
||||
self.store_active_thread_receiver().await;
|
||||
if matches!(presentation, ThreadAttachPresentation::FreshWithDraft) {
|
||||
self.reset_transcript_state_after_clear();
|
||||
tui.clear_pending_history_lines();
|
||||
tui.defer_thread_switch_clear();
|
||||
} else {
|
||||
self.reset_for_thread_switch(tui)?;
|
||||
}
|
||||
self.reset_transcript_state_after_clear();
|
||||
tui.clear_pending_history_lines();
|
||||
let presentation = match presentation {
|
||||
ThreadAttachPresentation::FreshAfterClear => ThreadAttachPresentation::Fresh,
|
||||
ThreadAttachPresentation::FreshWithDraft => {
|
||||
tui.defer_thread_switch_clear();
|
||||
presentation
|
||||
}
|
||||
ThreadAttachPresentation::Fresh | ThreadAttachPresentation::SessionLineage => {
|
||||
tui.clear_for_thread_switch()?;
|
||||
presentation
|
||||
}
|
||||
};
|
||||
self.pending_thread_switch_resets += 1;
|
||||
self.app_event_tx.send(
|
||||
if matches!(presentation, ThreadAttachPresentation::FreshWithDraft) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Queued resume history replaces the provisional loading frame only when it is ready to render.
|
||||
//! Explicit local launch permissions remain runtime overrides across new sessions and reconnects.
|
||||
|
||||
use super::backend_selection::NewBackendSession;
|
||||
use super::reconnect::ReconnectState;
|
||||
use super::startup_bootstrap::bootstrap_server_owned_start;
|
||||
use super::startup_bootstrap::uses_server_owned_fresh_bootstrap;
|
||||
@@ -242,6 +243,7 @@ impl App {
|
||||
initial_backends: super::backend_sessions::InitialBackendSessions,
|
||||
mut config: Config,
|
||||
launch_cwd: PathBuf,
|
||||
backend_preference_context: backend_selection::BackendPreferenceContext,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
loader_overrides: LoaderOverrides,
|
||||
@@ -984,6 +986,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: Some(workspace_command_runner),
|
||||
backend_preference_context,
|
||||
config,
|
||||
local_settings,
|
||||
launch_cwd,
|
||||
@@ -1292,6 +1295,12 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
|
||||
let mut backends =
|
||||
super::backend_sessions::BackendSessions::from_initial(app_server, other_backends);
|
||||
if matches!(
|
||||
app.app_server_target,
|
||||
AppServerTarget::Embedded | AppServerTarget::LocalDaemon { .. }
|
||||
) {
|
||||
backends.set_preference_key(backends.displayed_owner(), "local");
|
||||
}
|
||||
|
||||
let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason {
|
||||
Ok(exit_reason)
|
||||
@@ -1299,6 +1308,14 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
loop {
|
||||
let displayed_session = backends.displayed;
|
||||
let displayed_owner = backends.displayed_owner();
|
||||
let multi_backend = app.config.features.enabled(Feature::TuiMultiBackend);
|
||||
let next_backend = if multi_backend && app.can_cycle_conversation_backend() {
|
||||
backends.next_listening_backend(displayed_owner)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let current_label = next_backend.and_then(|_| backends.displayed_name());
|
||||
app.chat_widget.set_new_backend_control(current_label);
|
||||
if !backends.is_listening(displayed_owner)
|
||||
&& !app.pending_startup_thread_start
|
||||
&& !app.reconnect.offline
|
||||
@@ -1452,6 +1469,22 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
AppRunControl::Continue
|
||||
}
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
let switch_backend = match &event {
|
||||
AppEvent::ChooseNewSessionCheckout { name } | AppEvent::NewSession { name }
|
||||
if multi_backend => app.other_new_session_backend(&backends).await
|
||||
.map(|owner| (owner, NewBackendSession::Fresh { name: name.clone() })),
|
||||
AppEvent::CycleNewConversationBackend => {
|
||||
let Some(owner) = next_backend else { continue };
|
||||
Some((owner, NewBackendSession::CarryDraft))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((owner, session)) = switch_backend {
|
||||
if let Err(message) = Box::pin(app.start_new_session_on_other_backend(tui, &mut backends, owner, session)).await {
|
||||
app.chat_widget.add_error_message(message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let AppEvent::ResumeBackendSession(target) = event {
|
||||
match app.resume_backend_session(tui, &mut backends, target).await {
|
||||
Ok(AppRunControl::Continue) => continue,
|
||||
|
||||
@@ -44,6 +44,10 @@ pub(crate) async fn make_test_app() -> Box<App> {
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: None,
|
||||
backend_preference_context: backend_selection::BackendPreferenceContext::new(
|
||||
&config,
|
||||
Some(config.cwd.to_path_buf()),
|
||||
),
|
||||
launch_cwd: config.cwd.to_path_buf(),
|
||||
runtime_working_directory_override: None,
|
||||
local_settings: crate::local_settings::LocalSettings::from(&config),
|
||||
|
||||
@@ -6837,6 +6837,10 @@ async fn make_test_app() -> Box<App> {
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: None,
|
||||
backend_preference_context: backend_selection::BackendPreferenceContext::new(
|
||||
&config,
|
||||
Some(config.cwd.to_path_buf()),
|
||||
),
|
||||
launch_cwd: config.cwd.to_path_buf(),
|
||||
runtime_working_directory_override: None,
|
||||
local_settings: crate::local_settings::LocalSettings::from(&config),
|
||||
@@ -6965,6 +6969,10 @@ pub(super) async fn make_test_app_with_channels() -> (
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: None,
|
||||
backend_preference_context: backend_selection::BackendPreferenceContext::new(
|
||||
&config,
|
||||
Some(config.cwd.to_path_buf()),
|
||||
),
|
||||
launch_cwd: config.cwd.to_path_buf(),
|
||||
runtime_working_directory_override: None,
|
||||
local_settings: crate::local_settings::LocalSettings::from(&config),
|
||||
|
||||
@@ -47,6 +47,7 @@ async fn backend_sessions_parked_request_does_not_hide_disconnect() -> Result<()
|
||||
let mut backends = BackendSessions::new(displayed);
|
||||
assert_eq!(backends.next_listening_backend(displayed_id), None);
|
||||
backends.insert(background);
|
||||
backends.set_preference_key(background_id, "remote.work");
|
||||
let initial = backends.displayed;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
@@ -157,6 +158,10 @@ async fn backend_sessions_parked_request_does_not_hide_disconnect() -> Result<()
|
||||
assert_eq!(owner, background_id);
|
||||
assert!(closed.is_none());
|
||||
assert!(!backends.is_listening(background_id));
|
||||
assert_eq!(
|
||||
backends.preferred_listening_backend(Some("remote.work")),
|
||||
Some(displayed_id)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -188,6 +193,22 @@ async fn backend_sessions_poll_three_sources_and_isolate_closed_or_paused_stream
|
||||
}
|
||||
let mut sessions = sessions.into_iter();
|
||||
let mut backends = BackendSessions::from_initial(sessions.next().unwrap(), sessions.collect());
|
||||
backends.set_preference_key(owners[2], "remote.work");
|
||||
backends.set_preference_key(owners[0], "local");
|
||||
backends.set_preference_key(owners[1], "cloud");
|
||||
assert_eq!(
|
||||
backends.preferred_listening_backend(Some("remote.work")),
|
||||
Some(owners[2])
|
||||
);
|
||||
assert_eq!(
|
||||
backends.preferred_listening_backend(Some("cloud")),
|
||||
Some(owners[1])
|
||||
);
|
||||
assert_eq!(
|
||||
backends.preferred_listening_backend(Some("missing")),
|
||||
Some(owners[0])
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
owners
|
||||
.iter()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::App;
|
||||
use super::AppEvent;
|
||||
use super::AppServerSession;
|
||||
use super::Result;
|
||||
use super::RuntimeKeymap;
|
||||
@@ -67,6 +68,61 @@ fn ctrl(ch: char) -> KeyEvent {
|
||||
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_thread_backend_chord_preserves_draft_until_first_submission() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf());
|
||||
app.chat_widget.handle_thread_session(session);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget
|
||||
.apply_external_edit("first draft".to_string());
|
||||
app.chat_widget
|
||||
.set_new_backend_control(Some("Cloud".to_string()));
|
||||
let screen = render_bottom_popup(&app.chat_widget, 80);
|
||||
insta::assert_snapshot!(screen.lines().find(|line| line.trim_start().starts_with("Cloud")).unwrap().trim(), @"Cloud ⌃x b to cycle");
|
||||
|
||||
// Cursor movement remains available while the backend selector is visible.
|
||||
let key = app.route_key_chord_event(&mut tui, ctrl('b')).unwrap();
|
||||
app.chat_widget.handle_key_event(key);
|
||||
app.chat_widget.handle_paste("!".to_string());
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "first draf!t");
|
||||
|
||||
assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None);
|
||||
let key = app
|
||||
.route_key_chord_event(&mut tui, KeyCode::Char('b').into())
|
||||
.expect("backend chord dispatch");
|
||||
app.chat_widget.handle_key_event(key);
|
||||
assert!(
|
||||
std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::CycleNewConversationBackend))
|
||||
);
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "first draf!t");
|
||||
|
||||
// A single-key remap takes precedence over cursor movement in the same draft.
|
||||
let config = toml::from_str("[global]\nchange_host = 'ctrl-b'")?;
|
||||
app.keymap = RuntimeKeymap::from_config(&config).unwrap();
|
||||
app.chat_widget.apply_keymap_update(config, &app.keymap);
|
||||
app.chat_widget.handle_key_event(ctrl('b'));
|
||||
assert!(
|
||||
std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::CycleNewConversationBackend))
|
||||
);
|
||||
|
||||
app.chat_widget.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, 80)
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("Cloud"))
|
||||
);
|
||||
|
||||
app.chat_widget.handle_key_event(ctrl('b'));
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::CycleNewConversationBackend))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() -> Result<()> {
|
||||
for (input, command, expected) in [
|
||||
|
||||
@@ -452,10 +452,80 @@ async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<(
|
||||
.join("\n");
|
||||
if fail_read {
|
||||
insta::assert_snapshot!(errors, @"■ Failed to read new session defaults: config/read failed in TUI");
|
||||
server.shutdown().await?;
|
||||
} else {
|
||||
insta::assert_snapshot!(errors, @"■ Failed to start a fresh session through the app server: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603)");
|
||||
|
||||
let source = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let source_id = source.backend_id();
|
||||
let destination = server.backend_id();
|
||||
let mut backends = BackendSessions::from_initial(source, vec![server]);
|
||||
let destination_cwd = tempdir()?;
|
||||
std::fs::create_dir(destination_cwd.path().join(".codex"))?;
|
||||
std::fs::write(
|
||||
destination_cwd.path().join(".codex/hooks.json"),
|
||||
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
|
||||
)?;
|
||||
// Model a destination whose trust was saved before thread/start failed.
|
||||
crate::legacy_core::config::set_project_trust_level(
|
||||
home.path(),
|
||||
destination_cwd.path(),
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mut settings = app.backend_settings();
|
||||
settings.config.cwd = destination_cwd.path().to_path_buf().abs();
|
||||
backends.settings.insert(destination, settings.clone());
|
||||
backends.set_preference_key(destination, "remote.work");
|
||||
let preference_path = app.local_settings.user_config_path.clone();
|
||||
let original_preferences = std::fs::read(&preference_path).ok();
|
||||
let error = app
|
||||
.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
destination,
|
||||
crate::app::backend_selection::NewBackendSession::CarryDraft,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.contains("replacement unavailable"));
|
||||
assert_eq!(std::fs::read(&preference_path).ok(), original_preferences);
|
||||
assert_eq!(backends.displayed_owner(), source_id);
|
||||
assert_eq!(app.config, original_config);
|
||||
assert_eq!(app.active_thread_id, Some(current));
|
||||
assert!(app.active_thread_rx.is_some());
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(current));
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"keep this request"
|
||||
);
|
||||
let (retry, retry_requests, retry_proxy) = start_recording_app_server(
|
||||
&app.config,
|
||||
/*blocked_thread_list*/ None,
|
||||
/*failed_thread_name*/ None,
|
||||
)
|
||||
.await?;
|
||||
let retry_id = retry.backend_id();
|
||||
backends.insert(retry);
|
||||
backends.settings.insert(retry_id, settings);
|
||||
app.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
retry_id,
|
||||
crate::app::backend_selection::NewBackendSession::CarryDraft,
|
||||
)
|
||||
.await
|
||||
.expect("retry switching to the trusted destination");
|
||||
assert_eq!(backends.displayed_owner(), retry_id);
|
||||
assert_eq!(recorded_params(&retry_requests, "thread/start").len(), 1);
|
||||
assert_eq!(
|
||||
recorded_params(&retry_requests, "hooks/list")[0]["cwds"],
|
||||
serde_json::json!([destination_cwd.path()])
|
||||
);
|
||||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("SessionStart"));
|
||||
backends.shutdown().await?;
|
||||
retry_proxy.await??;
|
||||
}
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
}
|
||||
Ok(())
|
||||
@@ -465,7 +535,7 @@ async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<(
|
||||
async fn backend_handoff_errors_are_snapshotted() -> Result<()> {
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
let source = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let (destination, _, destination_proxy) = start_recording_app_server_with_history(
|
||||
let (destination, requests, destination_proxy) = start_recording_app_server_with_history(
|
||||
&app.config,
|
||||
HistoryCapabilities::ConfigReadFails,
|
||||
/*blocked_thread_list*/ None,
|
||||
@@ -487,14 +557,27 @@ async fn backend_handoff_errors_are_snapshotted() -> Result<()> {
|
||||
|
||||
app.resume_backend_session(&mut tui, &mut backends, target.clone())
|
||||
.await?;
|
||||
insta::assert_snapshot!(next_history_message(&mut events), @"■ This backend has no connection settings. Open the session picker again.");
|
||||
insta::assert_snapshot!(next_history_message(&mut events), @"■ Connection settings are unavailable. Open the session picker again.");
|
||||
|
||||
backends
|
||||
.settings
|
||||
.insert(destination_id, app.backend_settings());
|
||||
app.resume_backend_session(&mut tui, &mut backends, target)
|
||||
.await?;
|
||||
insta::assert_snapshot!(next_history_message(&mut events), @"■ Unable to check folder trust: config/read failed while checking remote project trust");
|
||||
let trust_error = next_history_message(&mut events);
|
||||
insta::assert_snapshot!(trust_error, @"■ Unable to check folder trust: config/read failed while checking remote project trust");
|
||||
|
||||
// Starting a fresh thread must observe the same destination trust check as resuming one.
|
||||
app.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
destination_id,
|
||||
crate::app::backend_selection::NewBackendSession::Fresh { name: None },
|
||||
)
|
||||
.await
|
||||
.expect("trust failure is shown in the conversation");
|
||||
assert_eq!(next_history_message(&mut events), trust_error);
|
||||
assert!(recorded_params(&requests, "thread/start").is_empty());
|
||||
|
||||
backends.shutdown().await?;
|
||||
destination_proxy.await??;
|
||||
@@ -797,3 +880,49 @@ async fn provider_continuation_starts_current_provider_and_drafts_saved_referenc
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_cycling_waits_for_foreground_and_parked_voice_calls() -> Result<()> {
|
||||
for parked in [false, true] {
|
||||
let (mut app, _events, _ops) = make_test_app_with_channels().await;
|
||||
let source = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let destination = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let source_id = source.backend_id();
|
||||
let destination_id = destination.backend_id();
|
||||
let thread_id = ThreadId::new();
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(thread_id, app.config.cwd.to_path_buf()),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.can_cycle_conversation_backend());
|
||||
crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
|
||||
if parked {
|
||||
let (visible, _, _, _) =
|
||||
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
|
||||
app.background_voice = Some(Box::new(std::mem::replace(&mut app.chat_widget, visible)));
|
||||
}
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("keep draft".into());
|
||||
assert!(!app.can_cycle_conversation_backend());
|
||||
let mut backends = BackendSessions::from_initial(source, vec![destination]);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let error = app
|
||||
.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
destination_id,
|
||||
crate::app::backend_selection::NewBackendSession::CarryDraft,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
insta::allow_duplicates! {
|
||||
insta::assert_snapshot!(error, @"End the voice call before switching.");
|
||||
}
|
||||
assert_eq!(backends.displayed_owner(), source_id);
|
||||
assert_eq!(app.voice_owner_thread_id(), Some(thread_id));
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "keep draft");
|
||||
backends.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4663,7 +4663,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_session_applies_requested_name() -> Result<()> {
|
||||
fn fresh_session_keeps_its_name_and_draft_when_cycling_backends() -> Result<()> {
|
||||
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
std::thread::Builder::new()
|
||||
@@ -4678,6 +4678,43 @@ fn fresh_session_applies_requested_name() -> Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs());
|
||||
app.config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File;
|
||||
app.local_settings.user_config_path = codex_home.path().join("config.toml").abs();
|
||||
let local_project = tempdir()?;
|
||||
std::fs::create_dir(local_project.path().join(".git"))?;
|
||||
std::fs::write(local_project.path().join(".git/HEAD"), "ref: refs/heads/main\n")?;
|
||||
app.launch_cwd = local_project.path().to_path_buf();
|
||||
app.config.cwd = local_project.path().abs();
|
||||
app.backend_preference_context =
|
||||
super::super::backend_selection::BackendPreferenceContext::new(&app.config, Some(app.launch_cwd.clone()));
|
||||
let write_user = |user: &str| {
|
||||
app_test_support::write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
app_test_support::ChatGptAuthFixture::new("test-token")
|
||||
.chatgpt_user_id(user)
|
||||
.plan_type("plus"),
|
||||
codex_login::AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("write preference user")
|
||||
};
|
||||
write_user("user-a");
|
||||
let project_key = codex_config::loader::project_trust_key(local_project.path());
|
||||
let mut expected_defaults = std::collections::BTreeMap::from([
|
||||
(project_key.clone(), std::collections::BTreeMap::from([
|
||||
("chatgpt:user-b".to_string(), "local".to_string()),
|
||||
])),
|
||||
("other-project".to_string(), std::collections::BTreeMap::from([
|
||||
("chatgpt:user-a".to_string(), "remote.other".to_string()),
|
||||
])),
|
||||
]);
|
||||
app.local_settings.tui.default_hosts = expected_defaults.clone();
|
||||
std::fs::write(
|
||||
&app.local_settings.user_config_path,
|
||||
toml::to_string(&serde_json::json!({
|
||||
"projects": { (project_key.clone()): { "trust_level": "trusted" } },
|
||||
"tui": { "default_hosts": expected_defaults },
|
||||
}))?,
|
||||
)?;
|
||||
let (mut app_server, requests, proxy) = start_recording_app_server(
|
||||
&app.config,
|
||||
/*blocked_thread_list*/ None,
|
||||
@@ -4685,6 +4722,10 @@ fn fresh_session_applies_requested_name() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let init = app.chatwidget_init_for_forked_or_resumed_thread(
|
||||
&mut tui, app.config.clone(), /*initial_user_message*/ None,
|
||||
);
|
||||
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
|
||||
app.start_fresh_session(
|
||||
&mut tui,
|
||||
@@ -4699,6 +4740,7 @@ fn fresh_session_applies_requested_name() -> Result<()> {
|
||||
.chat_widget
|
||||
.thread_id()
|
||||
.expect("fresh session should have a thread id");
|
||||
assert!(app.agents_overview.blank_sessions.contains_key(&thread_id));
|
||||
assert_eq!(app.chat_widget.thread_name(), Some("Add User".to_string()));
|
||||
assert!(
|
||||
requests
|
||||
@@ -4712,8 +4754,134 @@ fn fresh_session_applies_requested_name() -> Result<()> {
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
assert_eq!(thread.name.as_deref(), Some("Add User"));
|
||||
// Deliver the metadata notification that populates Command Center.
|
||||
app.track_agents_overview_notification(&ServerNotification::ThreadStarted(
|
||||
codex_app_server_protocol::ThreadStartedNotification { thread: thread.clone() },
|
||||
));
|
||||
let retained_thread = ThreadId::new();
|
||||
let mut retained = thread;
|
||||
retained.id = retained_thread.to_string();
|
||||
retained.name = Some("Other conversation".to_string());
|
||||
app.agents_overview.threads.insert(retained_thread, Some(retained));
|
||||
|
||||
app_server.shutdown().await?;
|
||||
app.chat_widget.insert_str("unsent draft");
|
||||
app.agents_overview.pending_usage = Some((thread_id, uuid::Uuid::new_v4()));
|
||||
app.agents_overview.usage_disabled = true;
|
||||
let cwd = tempdir()?;
|
||||
let mut settings = app.backend_settings();
|
||||
settings.config.cwd = cwd.path().to_path_buf().abs();
|
||||
let other = crate::start_embedded_app_server_for_picker(&settings.config).await?;
|
||||
let destination = other.backend_id();
|
||||
let source = app_server.backend_id();
|
||||
let mut backends = crate::app::backend_sessions::BackendSessions::from_initial(
|
||||
app_server,
|
||||
vec![other],
|
||||
);
|
||||
backends.settings.insert(destination, settings);
|
||||
backends.set_preference_key(source, "local");
|
||||
backends.set_preference_key(destination, "remote.work");
|
||||
assert_eq!(app.other_new_session_backend(&backends).await, None);
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
backends.session_at_mut(backends.displayed),
|
||||
AppEvent::ChooseNewSessionCheckout { name: None },
|
||||
).await?;
|
||||
assert!(render_bottom_popup(&app.chat_widget, 80).contains("Use current Git worktree"));
|
||||
app.chat_widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(thread_id));
|
||||
assert_eq!(recorded_params(&requests, "thread/start").len(), 1);
|
||||
let preference_path = app.local_settings.user_config_path.clone();
|
||||
let saved_defaults = || -> Result<std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>> {
|
||||
let saved: toml::Value =
|
||||
toml::from_str(&std::fs::read_to_string(&preference_path)?)?;
|
||||
Ok(saved["tui"]["default_hosts"].clone().try_into()?)
|
||||
};
|
||||
assert!(app.can_cycle_conversation_backend());
|
||||
app.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
destination,
|
||||
crate::app::backend_selection::NewBackendSession::CarryDraft,
|
||||
)
|
||||
.await
|
||||
.expect("switch the named conversation with its draft");
|
||||
assert_eq!(backends.displayed_owner(), destination);
|
||||
expected_defaults.get_mut(&project_key).unwrap().insert(
|
||||
"chatgpt:user-a".to_string(), "remote.work".to_string(),
|
||||
);
|
||||
assert_eq!(saved_defaults()?, expected_defaults);
|
||||
assert_eq!(app.local_settings.tui.default_hosts, expected_defaults);
|
||||
write_user("user-b");
|
||||
assert_eq!(app.other_new_session_backend(&backends).await, Some(source));
|
||||
write_user("user-a");
|
||||
assert_eq!(app.other_new_session_backend(&backends).await, None);
|
||||
|
||||
assert_eq!(app.agents_overview.pending_usage, None);
|
||||
assert!(!app.agents_overview.usage_disabled);
|
||||
assert_eq!(app.config.cwd.as_path(), cwd.path());
|
||||
assert_eq!(app.chat_widget.thread_name().as_deref(), Some("Add User"));
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "unsent draft");
|
||||
let destination_thread = app.chat_widget.thread_id().expect("destination thread");
|
||||
assert_ne!(destination_thread, thread_id);
|
||||
let view = app.agents_overview_view(
|
||||
app.agents_overview.threads.values().flatten().cloned().collect(),
|
||||
/*selected_thread_id*/ None,
|
||||
);
|
||||
assert_eq!(view.thread_ids(), vec![retained_thread]);
|
||||
insta::assert_debug_snapshot!(view.thread_ids().into_iter().map(|id| {
|
||||
app.agents_overview.threads[&id].as_ref().unwrap().name.as_deref().unwrap()
|
||||
}).collect::<Vec<_>>(), @r#"
|
||||
[
|
||||
"Other conversation",
|
||||
]
|
||||
"#);
|
||||
assert!(!app.agents_overview.blank_sessions.contains_key(&thread_id));
|
||||
assert!(!app.agents_overview.input_states.contains_key(&thread_id));
|
||||
assert!(!app.thread_event_channels.contains_key(&thread_id));
|
||||
assert_eq!(
|
||||
recorded_params(&requests, "thread/unsubscribe"),
|
||||
vec![serde_json::json!({"threadId": thread_id.to_string()})]
|
||||
);
|
||||
app.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
source,
|
||||
crate::app::backend_selection::NewBackendSession::CarryDraft,
|
||||
)
|
||||
.await
|
||||
.expect("switch back with the draft");
|
||||
assert_eq!(backends.displayed_owner(), source);
|
||||
expected_defaults.get_mut(&project_key).unwrap().insert(
|
||||
"chatgpt:user-a".to_string(), "local".to_string(),
|
||||
);
|
||||
assert_eq!(saved_defaults()?, expected_defaults);
|
||||
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "unsent draft");
|
||||
assert!(
|
||||
!app.agents_overview
|
||||
.blank_sessions
|
||||
.contains_key(&destination_thread)
|
||||
);
|
||||
assert!(
|
||||
!app.agents_overview
|
||||
.input_states
|
||||
.contains_key(&destination_thread)
|
||||
);
|
||||
assert!(!app.thread_event_channels.contains_key(&destination_thread));
|
||||
// An automatic fresh session may use another connection without changing
|
||||
// the user's last explicit choice.
|
||||
app.start_new_session_on_other_backend(
|
||||
&mut tui,
|
||||
&mut backends,
|
||||
destination,
|
||||
crate::app::backend_selection::NewBackendSession::Fresh { name: None },
|
||||
)
|
||||
.await
|
||||
.expect("start without an explicit default change");
|
||||
assert_eq!(saved_defaults()?, expected_defaults);
|
||||
assert_eq!(app.local_settings.tui.default_hosts, expected_defaults);
|
||||
assert_eq!(app.new_thread_host_preference().await, Some("local"));
|
||||
backends.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -29,6 +29,14 @@ pub(super) async fn run_startup_for_test(
|
||||
initial_backends,
|
||||
config.clone(),
|
||||
config.cwd.to_path_buf(),
|
||||
backend_selection::BackendPreferenceContext::new(
|
||||
&config,
|
||||
crate::backend_preference_local_cwd(
|
||||
config.cwd.as_path(),
|
||||
&app_server_target,
|
||||
&EnvironmentManager::default_for_tests(),
|
||||
),
|
||||
),
|
||||
Vec::new(),
|
||||
ConfigOverrides::default(),
|
||||
LoaderOverrides::default(),
|
||||
|
||||
@@ -1534,6 +1534,7 @@ impl App {
|
||||
}
|
||||
match presentation {
|
||||
ThreadAttachPresentation::Fresh
|
||||
| ThreadAttachPresentation::FreshAfterClear
|
||||
| ThreadAttachPresentation::FreshWithDraft
|
||||
| ThreadAttachPresentation::SessionLineage => {
|
||||
self.chat_widget.handle_thread_session(session);
|
||||
|
||||
@@ -626,19 +626,36 @@ impl App {
|
||||
self.keymap = keymap;
|
||||
self.merge_startup_warnings(tui, &history_cell::StartupWarningsCell::default());
|
||||
self.restore_runtime_theme_from_config();
|
||||
if !app_server.uses_remote_workspace()
|
||||
&& !crate::uses_remote_workspace_or_environment(
|
||||
&self.app_server_target,
|
||||
&self.environment_manager,
|
||||
)
|
||||
{
|
||||
self.backend_preference_context.local_cwd = Some(cwd.to_path_buf());
|
||||
}
|
||||
self.runtime_working_directory_override = Some(cwd.to_path_buf());
|
||||
if let Some(message) = project_config_warning(&self.config) {
|
||||
self.chat_widget.add_warning_message(message);
|
||||
}
|
||||
let message = format!("Working directory changed to: {}", cwd.display());
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
self.review_folder_hooks_after_attach(app_server, cwd.to_path_buf())
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(super) async fn review_folder_hooks_after_attach(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
cwd: PathBuf,
|
||||
) {
|
||||
if !self.config.bypass_hook_trust {
|
||||
let load_review = crate::startup_hooks_review::load_startup_hooks_review_entry;
|
||||
let hooks = load_review(app_server.request_handle(), cwd.to_path_buf()).await;
|
||||
let hooks = load_review(app_server.request_handle(), cwd).await;
|
||||
if hooks.hooks.iter().any(crate::hooks_rpc::hook_needs_review) {
|
||||
self.chat_widget.open_hooks_browser(hooks);
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,19 @@ pub(crate) enum AppEvent {
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Resolve the /new host before offering choices about the displayed checkout.
|
||||
ChooseNewSessionCheckout {
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Start on the current worktree's host, ignoring the saved new-thread host.
|
||||
NewSessionInCurrentWorktree {
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Move an empty conversation to the next connected backend.
|
||||
CycleNewConversationBackend,
|
||||
|
||||
/// Continue a provider-locked conversation in a fresh chat using its saved history.
|
||||
ContinueProviderChat {
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -690,7 +690,7 @@ struct MentionCompletionTarget {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ShellPrefixHandling {
|
||||
pub(crate) enum ShellPrefixHandling {
|
||||
Interpret,
|
||||
Literal,
|
||||
}
|
||||
@@ -711,6 +711,7 @@ pub(super) struct ComposerDraft {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ComposerDraftSnapshot {
|
||||
pub(crate) text: String,
|
||||
pub(crate) shell_mode: Option<bool>,
|
||||
pub(crate) cursor: usize,
|
||||
pub(crate) text_elements: Vec<TextElement>,
|
||||
pub(crate) local_images: Vec<LocalImageAttachment>,
|
||||
@@ -1598,7 +1599,7 @@ impl ChatComposer {
|
||||
);
|
||||
}
|
||||
|
||||
fn set_text_content_with_shell_prefix(
|
||||
pub(super) fn set_text_content_with_shell_prefix(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
|
||||
@@ -21,6 +21,10 @@ impl ChatComposer {
|
||||
attachments.reset_local_images(draft.local_image_paths, &mut textarea);
|
||||
ComposerDraftSnapshot {
|
||||
text: draft.text,
|
||||
shell_mode: self
|
||||
.config
|
||||
.shell_commands_enabled
|
||||
.then_some(draft.is_bash_mode),
|
||||
cursor: draft.cursor,
|
||||
text_elements: draft.text_elements,
|
||||
local_images: attachments.local_images,
|
||||
|
||||
@@ -272,6 +272,7 @@ pub(crate) use chat_composer::InputResult;
|
||||
pub(crate) use chat_composer::KillBufferSnapshot;
|
||||
pub(crate) use chat_composer::QueuedInputAction;
|
||||
pub(crate) use chat_composer::RestrictedInputMode;
|
||||
pub(crate) use chat_composer::ShellPrefixHandling;
|
||||
pub(crate) use chat_composer_history::HistoryEntry;
|
||||
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
@@ -568,6 +569,10 @@ impl BottomPane {
|
||||
self.composer.record_pending_slash_command_history();
|
||||
}
|
||||
|
||||
pub(super) fn keymap(&self) -> &RuntimeKeymap {
|
||||
&self.keymap
|
||||
}
|
||||
|
||||
/// Replace all bottom-pane keymap caches from one resolved runtime keymap.
|
||||
///
|
||||
/// The bottom pane owns several input surfaces: composer, overlays, and
|
||||
@@ -1183,13 +1188,15 @@ impl BottomPane {
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
shell_prefix: ShellPrefixHandling,
|
||||
) {
|
||||
self.composer.edit_stored_draft(|composer| {
|
||||
composer.set_text_content_with_mention_bindings(
|
||||
composer.set_text_content_with_shell_prefix(
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
mention_bindings,
|
||||
shell_prefix,
|
||||
);
|
||||
composer.move_cursor_to_end();
|
||||
});
|
||||
|
||||
@@ -712,6 +712,7 @@ pub(crate) struct ChatWidget {
|
||||
safety_buffering_source: UserMessageSource,
|
||||
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
||||
chat_keymap: ChatKeymap,
|
||||
new_backend_label: Option<String>,
|
||||
/// Whether the preset submenu owns the next key.
|
||||
quick_dial_menu: bool,
|
||||
/// Ignore a held key after it dismisses the preset submenu.
|
||||
|
||||
@@ -260,6 +260,7 @@ impl ChatWidget {
|
||||
suppress_initial_user_message_submit: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
new_backend_label: None,
|
||||
quick_dial_menu: false,
|
||||
quick_dial_menu_key_guard: None,
|
||||
quit_shortcut_key: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_protocol::models::local_image_label_text;
|
||||
use crate::bottom_pane::ComposerDraftSnapshot;
|
||||
use crate::bottom_pane::KillBufferSnapshot;
|
||||
use crate::bottom_pane::MentionUsage;
|
||||
use crate::bottom_pane::ShellPrefixHandling;
|
||||
|
||||
use super::user_messages::remap_colliding_paste_placeholders;
|
||||
use super::*;
|
||||
@@ -24,6 +25,15 @@ impl ChatWidget {
|
||||
self.bottom_pane.restore_kill_buffer_snapshot(snapshot);
|
||||
}
|
||||
|
||||
pub(crate) fn composer_draft_snapshot(&mut self) -> ComposerDraftSnapshot {
|
||||
self.bottom_pane.flush_pending_composer_input();
|
||||
let mut draft = self.bottom_pane.composer_draft_snapshot();
|
||||
// This snapshot comes from an active host, where disabled shell commands
|
||||
// also mean a leading bang was entered literally.
|
||||
draft.shell_mode = Some(draft.shell_mode.unwrap_or(false));
|
||||
draft
|
||||
}
|
||||
|
||||
/// Restore the exact draft entered before the fully initialized composer became available.
|
||||
pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) {
|
||||
self.bottom_pane
|
||||
@@ -79,20 +89,23 @@ impl ChatWidget {
|
||||
.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,
|
||||
));
|
||||
self.restore_composer_state_with_shell_mode(
|
||||
Self::composer_state_from_user_message(
|
||||
merge_user_messages(vec![existing_message, startup_message]),
|
||||
pending_pastes,
|
||||
),
|
||||
existing_draft.shell_mode,
|
||||
);
|
||||
startup_offset
|
||||
.saturating_add(draft.cursor)
|
||||
.saturating_add(cursor_adjustment)
|
||||
} else if existing_has_content || !startup_has_content {
|
||||
existing_cursor
|
||||
} else {
|
||||
self.restore_composer_state(Self::composer_state_from_user_message(
|
||||
startup_message,
|
||||
draft.pending_pastes,
|
||||
));
|
||||
self.restore_composer_state_with_shell_mode(
|
||||
Self::composer_state_from_user_message(startup_message, draft.pending_pastes),
|
||||
draft.shell_mode,
|
||||
);
|
||||
draft.cursor
|
||||
};
|
||||
self.bottom_pane.set_composer_cursor(cursor);
|
||||
@@ -564,6 +577,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn restore_composer_state(&mut self, composer: ThreadComposerState) {
|
||||
self.restore_composer_state_with_shell_mode(composer, None);
|
||||
}
|
||||
|
||||
fn restore_composer_state_with_shell_mode(
|
||||
&mut self,
|
||||
composer: ThreadComposerState,
|
||||
shell_mode: Option<bool>,
|
||||
) {
|
||||
let ThreadComposerState {
|
||||
text,
|
||||
local_images,
|
||||
@@ -579,6 +600,11 @@ impl ChatWidget {
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
mention_bindings,
|
||||
if shell_mode == Some(false) {
|
||||
ShellPrefixHandling::Literal
|
||||
} else {
|
||||
ShellPrefixHandling::Interpret
|
||||
},
|
||||
);
|
||||
self.bottom_pane.set_composer_pending_pastes(pending_pastes);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
|
||||
self.bottom_pane.keymap_contexts()
|
||||
let contexts = self.bottom_pane.keymap_contexts();
|
||||
if self.visible_new_backend_control().is_some() {
|
||||
contexts.with_backend_cycling()
|
||||
} else {
|
||||
contexts
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_key_chord_reset(&mut self) -> bool {
|
||||
@@ -92,6 +97,35 @@ impl ChatWidget {
|
||||
self.handle_key_event_with_activation_key(key_event, /*activation_key*/ None)
|
||||
}
|
||||
|
||||
pub(crate) fn set_new_backend_control(&mut self, label: Option<String>) {
|
||||
if self.new_backend_label != label {
|
||||
self.new_backend_label = label;
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn can_choose_new_backend(&self) -> bool {
|
||||
!self.blocks_direct_input
|
||||
&& !self.is_user_turn_pending_or_running()
|
||||
&& self.last_turn_id().is_none()
|
||||
&& !self.input_queue.has_queued_follow_up_messages()
|
||||
&& self.input_queue.startup_submission.is_none()
|
||||
&& self.initial_user_message.is_none()
|
||||
}
|
||||
|
||||
pub(super) fn visible_new_backend_control(&self) -> Option<&str> {
|
||||
let label = self.new_backend_label.as_deref()?;
|
||||
(self.can_choose_new_backend()
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
&& !self.fork_in_progress
|
||||
&& !self.shortcut_overlay_visible()
|
||||
&& !self.quick_dial_menu
|
||||
&& self.safety_recourse.is_none()
|
||||
&& !self.has_provider_error()
|
||||
&& matches!(self.external_editor_state, ExternalEditorState::Closed))
|
||||
.then_some(label)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event_with_activation_key(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
@@ -134,6 +168,15 @@ impl ChatWidget {
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
|
||||
let keymap = self.bottom_pane.keymap();
|
||||
if keymap.app.change_host.is_pressed(key_event)
|
||||
&& self.visible_new_backend_control().is_some()
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CycleNewConversationBackend);
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
|
||||
if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) {
|
||||
self.bottom_pane
|
||||
.handle_key_event_with_activation_key(key_event, activation_key);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! appear to accept a remap while some handlers still respond to the old keys.
|
||||
|
||||
use codex_config::types::TuiKeymap;
|
||||
use codex_features::Feature;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::KeymapCaptureMode;
|
||||
@@ -154,6 +155,7 @@ impl ChatWidget {
|
||||
fn keymap_action_filter(&self) -> keymap_setup::KeymapActionFilter {
|
||||
keymap_setup::KeymapActionFilter {
|
||||
fast_mode_enabled: self.fast_mode_enabled(),
|
||||
multi_backend_enabled: self.config.features.enabled(Feature::TuiMultiBackend),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::provider_error::ProviderErrorNotice;
|
||||
use super::transcript::ActiveCellLayoutCache;
|
||||
use super::transcript::ActiveCellLayoutCacheKey;
|
||||
use super::*;
|
||||
use crate::keymap::KeymapContext;
|
||||
use crate::render::RectExt;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -296,6 +297,13 @@ impl ChatWidget {
|
||||
&& !self.bottom_pane.has_active_view()
|
||||
{
|
||||
RenderableItem::Borrowed(&pending.notice)
|
||||
} else if let Some(backend) = self.visible_new_backend_control() {
|
||||
let mut spans = vec![" ".into(), backend.into()];
|
||||
let keymap = self.bottom_pane.keymap();
|
||||
if let Some(key) = keymap.primary_hint(KeymapContext::Global, "change_host") {
|
||||
spans.extend([" ".into(), key.into(), " to cycle".dim()]);
|
||||
}
|
||||
RenderableItem::Owned(Box::new(Paragraph::new(Line::from(spans))))
|
||||
} else {
|
||||
return bottom_pane;
|
||||
};
|
||||
|
||||
@@ -996,6 +996,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
|
||||
mention: "figma".to_string(),
|
||||
path: user_skill_path.to_string_lossy().into_owned(),
|
||||
}],
|
||||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
@@ -1174,6 +1175,42 @@ async fn blocked_image_restore_with_remote_images_keeps_local_placeholder_mappin
|
||||
assert_eq!(chat.remote_image_urls(), remote_image_urls);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_handoff_preserves_literal_and_shell_bang() {
|
||||
for text in [
|
||||
"!echo literal",
|
||||
"!echo literal\nsecond line",
|
||||
" !echo literal",
|
||||
] {
|
||||
let (mut source, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.bottom_pane.insert_str(&format!("x{text}"));
|
||||
source.bottom_pane.set_composer_cursor(0);
|
||||
source.handle_key_event(KeyEvent::from(KeyCode::Delete));
|
||||
let draft = source.composer_draft_snapshot();
|
||||
assert_eq!(draft.text, text);
|
||||
assert_eq!(draft.shell_mode, Some(false));
|
||||
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.restore_startup_draft(draft.clone());
|
||||
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), draft);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_hidden_shell_payload_is_literal(ops.try_recv(), text.trim().to_string());
|
||||
}
|
||||
|
||||
let (mut source, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source
|
||||
.bottom_pane
|
||||
.set_composer_text("!pwd".into(), Vec::new(), Vec::new());
|
||||
let draft = source.composer_draft_snapshot();
|
||||
assert_eq!(draft.shell_mode, Some(true));
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.restore_startup_draft(draft);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_matches!(ops.try_recv(), Ok(Op::RunUserShellCommand { command }) if command == "pwd");
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -2679,6 +2716,7 @@ async fn image_preparation_failure_restores_full_input_without_submitting() {
|
||||
mention: "file".into(),
|
||||
path: "/tmp/skills/file/SKILL.md".into(),
|
||||
}],
|
||||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||||
);
|
||||
let draft = chat.bottom_pane.composer_draft_snapshot();
|
||||
if oversized {
|
||||
|
||||
@@ -989,6 +989,7 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer()
|
||||
)],
|
||||
Vec::new(),
|
||||
mention_bindings.clone(),
|
||||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
|
||||
@@ -807,6 +807,7 @@ async fn goal_slash_command_uses_plain_text_for_mentions() {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
||||
@@ -1251,6 +1252,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
@@ -2,6 +2,22 @@ use super::*;
|
||||
use crate::app_event::HistoryLookupResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_switch_snapshot_preserves_buffered_typing() {
|
||||
for typed in ["x", "several keys"] {
|
||||
let (mut chat, _events, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false);
|
||||
chat.insert_str("draft ");
|
||||
for ch in typed.chars() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||
}
|
||||
assert_eq!(
|
||||
chat.composer_draft_snapshot().text,
|
||||
format!("draft {typed}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_prompt_recall_precedes_cross_session_history() {
|
||||
for submit_follow_up in [false, true] {
|
||||
|
||||
@@ -36,12 +36,19 @@ async fn slash_new_and_fork_offer_checkout_choices_inside_local_git_repository()
|
||||
assert!(popup.contains("Create new Git worktree"), "popup: {popup}");
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
chat.set_feature_enabled(Feature::TuiMultiBackend, /*enabled*/ true);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/new named".into(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||||
let Ok(AppEvent::ChooseNewSessionCheckout { name }) = rx.try_recv() else {
|
||||
panic!("/new must resolve its host before offering checkout choices");
|
||||
};
|
||||
assert!(!chat.has_active_modal());
|
||||
assert_eq!(name.as_deref(), Some("named"));
|
||||
chat.open_session_checkout_picker(crate::app_event::ManagedWorktreeMode::New, name);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: Some(name) }) if name == "named");
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::NewSessionInCurrentWorktree { name: Some(name) }) if name == "named");
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/fork named".into(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
@@ -51,6 +58,7 @@ async fn slash_new_and_fork_offer_checkout_choices_inside_local_git_repository()
|
||||
mode: crate::app_event::ManagedWorktreeMode::Fork,
|
||||
name: Some(name),
|
||||
}) if name == "named");
|
||||
chat.set_feature_enabled(Feature::TuiMultiBackend, /*enabled*/ false);
|
||||
chat.set_local_worktree_operations(/*enabled*/ false);
|
||||
chat.dispatch_command(SlashCommand::New);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: None }));
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::worktree_browser::Request;
|
||||
const BROWSER_VIEW_ID: &str = "managed-worktrees";
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn managed_worktree_available(&self) -> bool {
|
||||
pub(crate) fn managed_worktree_available(&self) -> bool {
|
||||
self.local_worktree_operations && get_git_repo_root(self.config.cwd.as_path()).is_some()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ impl ChatWidget {
|
||||
mode: ManagedWorktreeMode,
|
||||
name: Option<String>,
|
||||
) {
|
||||
if mode == ManagedWorktreeMode::New
|
||||
&& self.config.features.enabled(Feature::TuiMultiBackend)
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ChooseNewSessionCheckout { name });
|
||||
return;
|
||||
}
|
||||
if !self.managed_worktree_available() {
|
||||
match mode {
|
||||
ManagedWorktreeMode::New => {
|
||||
@@ -35,6 +42,14 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_session_checkout_picker(mode, name);
|
||||
}
|
||||
|
||||
pub(crate) fn open_session_checkout_picker(
|
||||
&mut self,
|
||||
mode: ManagedWorktreeMode,
|
||||
name: Option<String>,
|
||||
) {
|
||||
let title = match mode {
|
||||
ManagedWorktreeMode::New => "Where should the new conversation run?",
|
||||
ManagedWorktreeMode::Fork => "Where should the forked conversation run?",
|
||||
@@ -48,7 +63,7 @@ impl ChatWidget {
|
||||
description: Some("Keep using the current working directory".to_string()),
|
||||
actions: vec![Box::new(move |tx| match mode {
|
||||
ManagedWorktreeMode::New => {
|
||||
tx.send(AppEvent::NewSession {
|
||||
tx.send(AppEvent::NewSessionInCurrentWorktree {
|
||||
name: current_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ pub(crate) struct RuntimeKeymap {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppKeymap {
|
||||
/// Change where a new conversation runs.
|
||||
pub(crate) change_host: Vec<KeyBinding>,
|
||||
/// Open the agent command center.
|
||||
pub(crate) open_agents: Vec<KeyBinding>,
|
||||
/// Open transcript overlay.
|
||||
@@ -723,6 +725,11 @@ impl RuntimeKeymap {
|
||||
.collect();
|
||||
|
||||
let app = AppKeymap {
|
||||
change_host: resolve_bindings(
|
||||
keymap.global.change_host.as_ref(),
|
||||
&defaults.app.change_host,
|
||||
"tui.keymap.global.change_host",
|
||||
)?,
|
||||
open_agents: resolve_bindings(
|
||||
keymap.global.open_agents.as_ref(),
|
||||
&defaults.app.open_agents,
|
||||
@@ -1697,6 +1704,7 @@ impl RuntimeKeymap {
|
||||
fn built_in_defaults() -> Self {
|
||||
Self {
|
||||
app: AppKeymap {
|
||||
change_host: Vec::new(),
|
||||
open_agents: default_bindings![],
|
||||
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
||||
find_transcript: default_bindings![plain(KeyCode::F(3))],
|
||||
@@ -2023,15 +2031,16 @@ impl RuntimeKeymap {
|
||||
/// backtracking, intentionally stay outside this configurable keymap.
|
||||
fn validate_conflicts(&self) -> Result<(), String> {
|
||||
for (action, bindings) in [
|
||||
("open_presets", &self.chat.open_presets),
|
||||
("toggle_voice", &self.chat.toggle_voice),
|
||||
("global.change_host", &self.app.change_host),
|
||||
("chat.open_presets", &self.chat.open_presets),
|
||||
("chat.toggle_voice", &self.chat.toggle_voice),
|
||||
(
|
||||
"previous_permission_mode",
|
||||
"chat.previous_permission_mode",
|
||||
&self.chat.previous_permission_mode,
|
||||
),
|
||||
("next_permission_mode", &self.chat.next_permission_mode),
|
||||
("prompt_stack_back", &self.chat.prompt_stack_back),
|
||||
("skip_question", &self.chat.skip_question),
|
||||
("chat.next_permission_mode", &self.chat.next_permission_mode),
|
||||
("chat.prompt_stack_back", &self.chat.prompt_stack_back),
|
||||
("chat.skip_question", &self.chat.skip_question),
|
||||
] {
|
||||
if bindings.iter().any(|binding| {
|
||||
let (code, modifiers) = binding.parts();
|
||||
@@ -2039,12 +2048,13 @@ impl RuntimeKeymap {
|
||||
|| matches!(code, KeyCode::Char(_)) && crate::key_hint::is_altgr(modifiers)
|
||||
}) {
|
||||
return Err(format!(
|
||||
"tui.keymap.chat.{action}: printable keys are reserved for text input"
|
||||
"tui.keymap.{action}: printable keys are reserved for text input"
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
for (action, bindings) in [
|
||||
("global.change_host", &self.app.change_host),
|
||||
("global.open_agents", &self.app.open_agents),
|
||||
("chat.open_presets", &self.chat.open_presets),
|
||||
("chat.toggle_voice", &self.chat.toggle_voice),
|
||||
@@ -2074,6 +2084,7 @@ impl RuntimeKeymap {
|
||||
}
|
||||
|
||||
let main_bindings = [
|
||||
("change_host", self.app.change_host.as_slice()),
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
|
||||
@@ -284,6 +284,7 @@ macro_rules! define_runtime_action_bindings {
|
||||
|
||||
define_runtime_action_bindings! {
|
||||
"global" => Global, app, global [
|
||||
change_host,
|
||||
open_agents,
|
||||
open_transcript,
|
||||
find_transcript,
|
||||
|
||||
@@ -53,6 +53,7 @@ const VOICE_TOGGLE: u32 = 1 << 17;
|
||||
const RAW_KEY_CAPTURE: u32 = 1 << 18;
|
||||
const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19;
|
||||
const TRANSCRIPT_ACTIONS: u32 = 1 << 20;
|
||||
const BACKEND_CYCLING: u32 = 1 << 21;
|
||||
|
||||
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
|
||||
|
||||
@@ -67,6 +68,11 @@ impl KeymapContextSet {
|
||||
Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | ACTIVITY_FOCUS)
|
||||
}
|
||||
|
||||
/// Offer backend cycling only while the new conversation control is visible.
|
||||
pub(crate) const fn with_backend_cycling(self) -> Self {
|
||||
Self(self.0 | BACKEND_CYCLING)
|
||||
}
|
||||
|
||||
/// Add only the detail-close action to an owned composer path.
|
||||
pub(crate) const fn with_transcript_close(self) -> Self {
|
||||
Self(self.0 | context_bit(KeymapContext::Pager) | TRANSCRIPT_CLOSE)
|
||||
@@ -152,6 +158,9 @@ impl KeymapContextSet {
|
||||
|
||||
/// Whether this input path can dispatch the action, including focus-specific exclusions.
|
||||
pub(crate) fn contains_action(self, action: KeymapActionId) -> bool {
|
||||
if action.context == KeymapContext::Global && action.action == "change_host" {
|
||||
return self.0 & BACKEND_CYCLING != 0;
|
||||
}
|
||||
if action.context == KeymapContext::Chat && action.action == "open_presets" {
|
||||
return self.contains(KeymapContext::Chat)
|
||||
&& self.contains(KeymapContext::Global)
|
||||
@@ -519,6 +528,7 @@ impl KeyChordMatcher {
|
||||
{
|
||||
let label = match binding.action.action {
|
||||
"open_presets" if pending.started_at.is_none() => "presets",
|
||||
"change_host" => "cycle",
|
||||
"focus_activity" => "activity",
|
||||
"find_transcript" => "find",
|
||||
"open_warnings" => "warnings",
|
||||
|
||||
@@ -23,6 +23,13 @@ pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeym
|
||||
.count(),
|
||||
);
|
||||
for (configured, context, action, completion, spec) in [
|
||||
(
|
||||
keymap.global.change_host.as_ref(),
|
||||
KeymapContext::Global,
|
||||
"change_host",
|
||||
'b',
|
||||
"leader b",
|
||||
),
|
||||
(
|
||||
keymap.global.select_transcript.as_ref(),
|
||||
KeymapContext::Global,
|
||||
|
||||
@@ -822,6 +822,7 @@ mod tests {
|
||||
fn fast_mode_action_filter() -> KeymapActionFilter {
|
||||
KeymapActionFilter {
|
||||
fast_mode_enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +900,10 @@ mod tests {
|
||||
let params = build_keymap_picker_params_with_filter(
|
||||
&runtime,
|
||||
&TuiKeymap::default(),
|
||||
fast_mode_action_filter(),
|
||||
KeymapActionFilter {
|
||||
multi_backend_enabled: true,
|
||||
fast_mode_enabled: true,
|
||||
},
|
||||
);
|
||||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||||
|
||||
|
||||
@@ -35,11 +35,13 @@ pub(super) struct KeymapActionDescriptor {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum KeymapActionFeature {
|
||||
FastMode,
|
||||
MultiBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct KeymapActionFilter {
|
||||
pub(crate) fast_mode_enabled: bool,
|
||||
pub(crate) multi_backend_enabled: bool,
|
||||
}
|
||||
|
||||
impl KeymapActionDescriptor {
|
||||
@@ -47,6 +49,7 @@ impl KeymapActionDescriptor {
|
||||
match self.required_feature {
|
||||
None => true,
|
||||
Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled,
|
||||
Some(KeymapActionFeature::MultiBackend) => filter.multi_backend_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +114,7 @@ macro_rules! define_keymap_actions {
|
||||
|
||||
define_keymap_actions! {
|
||||
global "Global" {
|
||||
change_host [MultiBackend] => "Change where a new conversation runs.",
|
||||
open_agents => "Open the agent command center.",
|
||||
open_transcript => "Open the transcript overlay.",
|
||||
find_transcript => "Find text in the full transcript.",
|
||||
@@ -298,6 +302,9 @@ define_keymap_actions! {
|
||||
/// parsed back into an action name, because underscores and casing are part of
|
||||
/// the stable config contract.
|
||||
pub(super) fn action_label(action: &str) -> String {
|
||||
if action == "change_host" {
|
||||
return "Change Where to Run".to_string();
|
||||
}
|
||||
action
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
|
||||
@@ -984,6 +984,18 @@ fn uses_remote_workspace_or_environment(
|
||||
.is_some_and(|environment| environment.is_remote())
|
||||
}
|
||||
|
||||
fn backend_preference_local_cwd(
|
||||
config_cwd: &Path,
|
||||
app_server_target: &AppServerTarget,
|
||||
environment_manager: &EnvironmentManager,
|
||||
) -> Option<PathBuf> {
|
||||
if uses_remote_workspace_or_environment(app_server_target, environment_manager) {
|
||||
std::env::current_dir().ok()
|
||||
} else {
|
||||
Some(config_cwd.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "validate selection even after the picker connection is closed"
|
||||
@@ -1235,6 +1247,15 @@ async fn run_ratatui_app(
|
||||
>,
|
||||
startup_draft: startup_draft::StartupDraft,
|
||||
) -> color_eyre::Result<AppExitInfo> {
|
||||
// Capture host credentials and project inputs before resume/fork reloads executor config.
|
||||
let backend_preference_context = app::backend_selection::BackendPreferenceContext::new(
|
||||
&initial_config,
|
||||
backend_preference_local_cwd(
|
||||
initial_config.cwd.as_path(),
|
||||
&app_server_target,
|
||||
&environment_manager,
|
||||
),
|
||||
);
|
||||
let backend_id = crate::backend_id::BackendId::new();
|
||||
let uses_remote_workspace = app_server_target.uses_remote_workspace();
|
||||
let workload_identity_selected = is_workload_identity_selected();
|
||||
@@ -2221,6 +2242,7 @@ async fn run_ratatui_app(
|
||||
crate::app::backend_sessions::InitialBackendSessions::singleton(app_server),
|
||||
config,
|
||||
current_cwd.to_path_buf(),
|
||||
backend_preference_context,
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
loader_overrides.clone(),
|
||||
|
||||
@@ -1394,6 +1394,19 @@ async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() ->
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
backend_preference_local_cwd(remote_only_cwd, &target, &environment_manager),
|
||||
std::env::current_dir().ok(),
|
||||
);
|
||||
assert_eq!(
|
||||
backend_preference_local_cwd(
|
||||
remote_only_cwd,
|
||||
&target,
|
||||
&EnvironmentManager::default_for_tests(),
|
||||
),
|
||||
Some(remote_only_cwd.to_path_buf()),
|
||||
);
|
||||
|
||||
let config_cwd = config_cwd_for_app_server_target(
|
||||
Some(remote_only_cwd),
|
||||
&target,
|
||||
|
||||
@@ -56,6 +56,7 @@ impl LocalSettings {
|
||||
config.tui_alternate_screen != codex_config::types::AltScreenMode::Never,
|
||||
),
|
||||
tui: Tui {
|
||||
default_hosts: Default::default(),
|
||||
notification_settings: config.tui_notifications.clone(),
|
||||
animations: config.animations,
|
||||
screen_reader_detection_done: None,
|
||||
@@ -158,6 +159,15 @@ impl LocalSettings {
|
||||
system_motion: crate::motion::MotionMode,
|
||||
screen_reader_default: crate::motion::MotionMode,
|
||||
) {
|
||||
self.tui.default_hosts = layers
|
||||
.effective_user_config()
|
||||
.and_then(|config| {
|
||||
config
|
||||
.get("tui")
|
||||
.and_then(|tui| tui.get("default_hosts"))
|
||||
.and_then(|value| value.clone().try_into().ok())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if screen_reader_default == crate::motion::MotionMode::Reduced {
|
||||
// Consult the current layers so preferences edited after startup still win.
|
||||
self.tui.animations &= layers
|
||||
|
||||
@@ -119,9 +119,10 @@ async fn system_motion_suppresses_animations_without_changing_saved_preferences(
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Result<()> {
|
||||
for config_text in [
|
||||
"",
|
||||
r#"
|
||||
for (config_text, expected_server) in [
|
||||
("", None),
|
||||
(
|
||||
r#"
|
||||
[tui]
|
||||
animations = false
|
||||
show_tooltips = false
|
||||
@@ -135,6 +136,8 @@ right_click_paste = "off"
|
||||
vim_mode_default = true
|
||||
terminal_resize_reflow_max_rows = 0
|
||||
session_picker_view = "comfortable"
|
||||
[tui.default_hosts.project_a]
|
||||
user_a = "remote.work"
|
||||
[tui.effects]
|
||||
shimmer = false
|
||||
[tui.rendering]
|
||||
@@ -148,9 +151,23 @@ max_bytes = 4096
|
||||
[notice]
|
||||
fast_default_opt_out = true
|
||||
"#,
|
||||
Some("remote.work"),
|
||||
),
|
||||
] {
|
||||
let home = tempfile::tempdir()?;
|
||||
std::fs::write(home.path().join("config.toml"), config_text)?;
|
||||
let project = tempfile::tempdir()?;
|
||||
let project_cwd = AbsolutePathBuf::from_absolute_path(project.path())?;
|
||||
std::fs::create_dir(project.path().join(".codex"))?;
|
||||
std::fs::write(
|
||||
project.path().join(".codex/config.toml"),
|
||||
"[tui]\ntheme = 'project-theme'\n[tui.default_hosts.project_a]\nuser_a = 'project-override'\nuser_b = 'project-only'\n",
|
||||
)?;
|
||||
crate::legacy_core::config::set_project_trust_level(
|
||||
home.path(),
|
||||
project.path(),
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
)?;
|
||||
let cli_overrides = vec![
|
||||
("tui.disable_paste_burst".into(), true.into()),
|
||||
("tui.right_click_paste".into(), "on".into()),
|
||||
@@ -163,11 +180,9 @@ fast_default_opt_out = true
|
||||
];
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.fallback_cwd(Some(project.path().to_path_buf()))
|
||||
.strict_config(true)
|
||||
.loader_overrides(LoaderOverrides {
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
})
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.cli_overrides(cli_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
@@ -176,13 +191,10 @@ fast_default_opt_out = true
|
||||
assert!(config.tui_fullscreen_transcript);
|
||||
let bootstrap = crate::legacy_core::config::load_config_toml_with_layer_stack(
|
||||
home.path(),
|
||||
/*cwd*/ None,
|
||||
Some(&project_cwd),
|
||||
cli_overrides,
|
||||
codex_config::ConfigLoadOptions {
|
||||
loader_overrides: LoaderOverrides {
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
},
|
||||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||||
strict_config: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -193,6 +205,24 @@ fast_default_opt_out = true
|
||||
LocalSettings::from(&config),
|
||||
);
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 1);
|
||||
assert_eq!(config.tui_theme.as_deref(), Some("project-theme"));
|
||||
assert!(
|
||||
LocalSettings::from(&config)
|
||||
.tui
|
||||
.default_hosts
|
||||
.get("project_a")
|
||||
.and_then(|users| users.get("user_b"))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
LocalSettings::from(&config)
|
||||
.tui
|
||||
.default_hosts
|
||||
.get("project_a")
|
||||
.and_then(|users| users.get("user_a"))
|
||||
.map(String::as_str),
|
||||
expected_server,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -144,7 +144,9 @@ fn log_inbound_app_event_with(logger: &SessionLogger, event: &AppEvent) {
|
||||
}
|
||||
|
||||
match event {
|
||||
AppEvent::NewSession { .. } => {
|
||||
AppEvent::NewSession { .. }
|
||||
| AppEvent::NewSessionInCurrentWorktree { .. }
|
||||
| AppEvent::CycleNewConversationBackend => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
|
||||
@@ -10,6 +10,7 @@ use pretty_assertions::assert_eq;
|
||||
fn snapshot(text: &str) -> ComposerDraftSnapshot {
|
||||
ComposerDraftSnapshot {
|
||||
text: text.to_string(),
|
||||
shell_mode: None,
|
||||
cursor: text.len(),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
|
||||
Reference in New Issue
Block a user