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:
Benjamin Carlsson
2026-11-02 23:19:03 +00:00
committed by copyberry
parent 857415f7f2
commit 26f73fba8f
49 changed files with 1273 additions and 118 deletions
+2
View File
@@ -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.
+5
View File
@@ -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,
+21
View File
@@ -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,
+2
View File
@@ -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,
+2 -1
View File
@@ -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>,
+354 -43
View File
@@ -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;
+37 -8
View File
@@ -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..]
+10 -2
View File
@@ -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 => {
+30 -5
View File
@@ -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))
}
}
+1 -1
View File
@@ -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,
+16 -14
View File
@@ -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) {
+33
View File
@@ -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,
+4
View File
@@ -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),
+8
View File
@@ -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()
+56
View File
@@ -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 [
+133 -4
View File
@@ -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(),
+1
View File
@@ -1534,6 +1534,7 @@ impl App {
}
match presentation {
ThreadAttachPresentation::Fresh
| ThreadAttachPresentation::FreshAfterClear
| ThreadAttachPresentation::FreshWithDraft
| ThreadAttachPresentation::SessionLineage => {
self.chat_widget.handle_thread_session(session);
+19 -2
View File
@@ -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();
}
}
+13
View File
@@ -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,
+8 -1
View File
@@ -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();
});
+1
View File
@@ -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,
+34 -8
View File
@@ -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);
}
+44 -1
View File
@@ -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),
}
}
+8
View File
@@ -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 }));
+17 -2
View File
@@ -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(),
});
}
+18 -7
View File
@@ -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()),
+1
View File
@@ -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,
+10
View File
@@ -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,
+5 -1
View File
@@ -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(&params, KEYMAP_ALL_TAB_ID);
+7
View File
@@ -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| {
+22
View File
@@ -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(),
+13
View File
@@ -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,
+10
View File
@@ -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
+42 -12
View File
@@ -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(())
}
+3 -1
View File
@@ -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(),