diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index e78a7a751c..2dbdfe2cc8 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -898,6 +898,11 @@ pub struct Tui { #[serde(default)] pub copy_on_select: CopyOnSelect, + /// Deselect transcript text after Copy on select confirms clipboard delivery. + /// Defaults to `false` and has no effect when Copy on select is off. + #[serde(default)] + pub copy_on_select_clear_selection: bool, + /// Right-click text paste fallback. Defaults to `auto` (Windows/WSL/Linux). /// `on` also enables macOS; neither mode reads over SSH or in recognized VS Code terminals. /// This controls the fullscreen fallback, not the terminal's own paste binding. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 401c5faef3..78b980893d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4648,6 +4648,11 @@ "default": "auto", "description": "Copy selected transcript or footer text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying." }, + "copy_on_select_clear_selection": { + "default": false, + "description": "Deselect transcript text after Copy on select confirms clipboard delivery. Defaults to `false` and has no effect when Copy on select is off.", + "type": "boolean" + }, "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" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ed22c53c09..abcb8c3208 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1271,6 +1271,7 @@ fn config_toml_deserializes_model_availability_nux() { fullscreen_transcript: true, mouse_scroll_speed: None, copy_on_select: Default::default(), + copy_on_select_clear_selection: false, right_click_paste: Default::default(), alternate_screen: AltScreenMode::default(), status_line: None, @@ -4417,6 +4418,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { fullscreen_transcript: true, mouse_scroll_speed: None, copy_on_select: Default::default(), + copy_on_select_clear_selection: false, right_click_paste: Default::default(), alternate_screen: AltScreenMode::Auto, status_line: None, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 9996db9abd..5318db9f90 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -62,6 +62,8 @@ pub enum ConfigEdit { SetSkillConfig { path: PathBuf, enabled: bool }, /// Set or clear a skill config entry under `[[skills.config]]` by name. SetSkillConfigByName { name: String, enabled: bool }, + /// Enable or disable TUI notifications without replacing custom event filters. + SetTuiNotificationsEnabled { enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -311,6 +313,22 @@ impl ConfigDocument { ConfigEdit::SetSkillConfigByName { name, enabled } => { Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } + ConfigEdit::SetTuiNotificationsEnabled { enabled } => { + let segments = ["tui", "notifications"]; + let custom_filters = segments + .iter() + .try_fold(self.doc.as_item(), |item, segment| { + item.as_table_like()?.get(segment) + }) + .and_then(TomlItem::as_array) + .is_some(); + if custom_filters { + anyhow::bail!( + "Event-specific notifications are configured. Edit those filters in the config file." + ); + } + Ok(self.write_value(&segments, value(*enabled))) + } ConfigEdit::SetPath { segments, value } => { if is_structured_feature_path(segments) && value.as_bool().is_some() { let mut existing = Some(self.doc.as_item()); diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 09c5eb0211..25fa3b1b04 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1495,28 +1495,36 @@ fn blocking_clear_path_noop_when_missing() { } #[test] -fn blocking_set_path_updates_notifications() { +fn blocking_notification_toggle_updates_boolean_and_preserves_custom_filters() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); + let config_path = codex_home.join(CONFIG_TOML_FILE); - let item = value(false); apply_blocking( codex_home, - &[ConfigEdit::SetPath { - segments: vec!["tui".to_string(), "notifications".to_string()], - value: item, - }], + &[ConfigEdit::SetTuiNotificationsEnabled { enabled: false }], ) .expect("apply"); + assert_eq!( + std::fs::read_to_string(&config_path).expect("read config"), + "[tui]\nnotifications = false\n" + ); - let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let config: TomlValue = toml::from_str(&raw).expect("parse config"); - let notifications = config - .get("tui") - .and_then(|item| item.as_table()) - .and_then(|tbl| tbl.get("notifications")) - .and_then(toml::Value::as_bool); - assert_eq!(notifications, Some(false)); + let custom_filters = "[tui]\nnotifications = [\"approval-requested\"]\n"; + std::fs::write(&config_path, custom_filters).expect("write config"); + let error = apply_blocking( + codex_home, + &[ConfigEdit::SetTuiNotificationsEnabled { enabled: false }], + ) + .expect_err("custom filters should reject a boolean toggle"); + assert_eq!( + error.to_string(), + "Event-specific notifications are configured. Edit those filters in the config file." + ); + assert_eq!( + std::fs::read_to_string(config_path).expect("read config"), + custom_filters + ); } #[tokio::test] diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a908f40632..49bd951e06 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -827,6 +827,9 @@ pub struct Config { /// Override the terminal-specific default for copying transcript mouse selections. pub tui_copy_on_select: codex_config::types::CopyOnSelect, + /// Whether to deselect transcript text after a confirmed automatic copy. + pub tui_copy_on_select_clear_selection: bool, + /// Right-click text paste fallback for the fullscreen TUI. pub tui_right_click_paste: codex_config::types::RightClickPaste, @@ -4597,6 +4600,10 @@ impl Config { .as_ref() .map(|tui| tui.copy_on_select) .unwrap_or_default(), + tui_copy_on_select_clear_selection: cfg + .tui + .as_ref() + .is_some_and(|tui| tui.copy_on_select_clear_selection), tui_right_click_paste: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 911952e863..608afbbe37 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -295,6 +295,7 @@ async fn new_config( tui_fullscreen_transcript: false, tui_mouse_scroll_speed: None, tui_copy_on_select: Default::default(), + tui_copy_on_select_clear_selection: false, tui_right_click_paste: Default::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, diff --git a/codex-rs/tui/src/activation_key_guard.rs b/codex-rs/tui/src/activation_key_guard.rs new file mode 100644 index 0000000000..80de51f14b --- /dev/null +++ b/codex-rs/tui/src/activation_key_guard.rs @@ -0,0 +1,71 @@ +//! Suppress a key held across a view transition from activating the child view. +//! +//! Terminals with enhanced keyboard reporting distinguish a held `Repeat` from a fresh `Press`. +//! Press-only terminals use a bounded window that extends while repeat events continue to arrive. + +use crate::key_hint::KeyBinding; +use crossterm::event::KeyEventKind; +use std::time::Duration; +use std::time::Instant; + +const INITIAL_GUARD: Duration = Duration::from_secs(/*secs*/ 1); +const LEGACY_REPEAT_GUARD: Duration = Duration::from_secs(/*secs*/ 1); + +#[derive(Clone, Copy)] +pub(crate) struct ActivationKeyGuard { + key: KeyBinding, + block_until: Instant, + key_releases_reported: bool, + active: bool, +} + +impl ActivationKeyGuard { + pub(crate) fn new_at(key: KeyBinding, now: Instant, key_releases_reported: bool) -> Self { + Self { + key, + block_until: now.checked_add(INITIAL_GUARD).unwrap_or(now), + key_releases_reported, + active: true, + } + } + + pub(crate) fn consume(&mut self, key: KeyBinding, kind: KeyEventKind) -> bool { + self.consume_at(key, kind, Instant::now()) + } + + fn consume_at(&mut self, key: KeyBinding, kind: KeyEventKind, now: Instant) -> bool { + if !self.active { + return false; + } + + if self.key.is_same_physical_key(key) { + if kind == KeyEventKind::Release { + self.active = false; + return true; + } + // Enhanced keyboard reporting labels a held key as Repeat, so a new Press is fresh. + if self.key_releases_reported && kind == KeyEventKind::Press { + self.active = false; + return false; + } + if kind == KeyEventKind::Repeat || now <= self.block_until { + let repeat_until = now.checked_add(LEGACY_REPEAT_GUARD).unwrap_or(now); + self.block_until = self.block_until.max(repeat_until); + return true; + } + self.active = false; + } else if matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat) { + self.active = false; + } + + false + } + + pub(crate) fn is_active(self) -> bool { + self.active + } +} + +#[cfg(test)] +#[path = "activation_key_guard_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/activation_key_guard_tests.rs b/codex-rs/tui/src/activation_key_guard_tests.rs new file mode 100644 index 0000000000..4dc347dbaf --- /dev/null +++ b/codex-rs/tui/src/activation_key_guard_tests.rs @@ -0,0 +1,39 @@ +use super::*; +use crate::key_hint; +use crossterm::event::KeyCode; + +#[test] +fn suppresses_activation_until_release_and_bounds_legacy_presses() { + let start = Instant::now(); + let key = key_hint::plain(KeyCode::Enter); + let mut guard = ActivationKeyGuard::new_at(key, start, /*key_releases_reported*/ true); + + assert!(guard.consume_at(key, KeyEventKind::Repeat, start)); + assert!(guard.consume_at(key, KeyEventKind::Release, start)); + assert!(!guard.consume_at(key, KeyEventKind::Press, start)); + + let mut guard = ActivationKeyGuard::new_at(key, start, /*key_releases_reported*/ false); + for elapsed in [900, 1_300] { + assert!(guard.consume_at( + key, + KeyEventKind::Press, + start + Duration::from_millis(/*millis*/ elapsed), + )); + } + assert!(!guard.consume_at( + key, + KeyEventKind::Press, + start + Duration::from_millis(/*millis*/ 2_301), + )); + assert!(!guard.is_active()); + + let mut guard = ActivationKeyGuard::new_at( + key_hint::plain(KeyCode::BackTab), + start, + /*key_releases_reported*/ true, + ); + let tab = key_hint::plain(KeyCode::Tab); + assert!(guard.consume_at(tab, KeyEventKind::Repeat, start)); + assert!(guard.consume_at(tab, KeyEventKind::Release, start)); + assert!(!guard.is_active()); +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c1eb2d323a..a25123f105 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -52,6 +52,7 @@ use crate::history_cell::HistoryCell; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; use crate::hooks_rpc::HookTrustUpdate; +use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; use crate::keymap::KeyChordMatcher; use crate::keymap::RuntimeKeymap; @@ -216,6 +217,7 @@ mod app_server_thread_ownership; mod backend_banner_fallback; mod background_requests; mod composer_hints; +mod config_panel; mod config_persistence; mod connector_mentions; mod daemon_menu; @@ -256,6 +258,7 @@ mod replay_filter; mod resize_reflow; mod resume_config; mod right_click_paste; +pub(crate) use right_click_paste::PasteEnvironment; mod safety_buffering; mod server_version_notice; mod session_lifecycle; @@ -529,6 +532,8 @@ struct InitialHistoryReplayBuffer { pub(crate) struct App { feature_write_lock: Arc>, + config_notification_test_generation: u64, + config_notification_test_pending: Option, model_catalog: Arc, pub(crate) session_telemetry: SessionTelemetry, pub(crate) app_event_tx: AppEventSender, @@ -574,6 +579,7 @@ pub(crate) struct App { pub(crate) enhanced_keys_supported: bool, pub(crate) keymap: RuntimeKeymap, pub(crate) key_chord_matcher: KeyChordMatcher, + routed_key_activation: Option, /// The foreground loop owns stream pacing; stopped animations have no timer. pub(crate) commit_animation: Option, @@ -899,6 +905,9 @@ impl App { if self.reconnect.offline && !transcript_owns_input && !self.chat_widget.keymap_contexts().is_warnings() + && !self + .chat_widget + .active_view_accepts_input_when_disconnected() && let TuiEvent::Key(key) = &event && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) && key.modifiers.contains(KeyModifiers::CONTROL) @@ -924,6 +933,11 @@ impl App { { self.cancel_pending_key_chord(); } + if matches!(&event, TuiEvent::FocusLost) + || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) + { + self.chat_widget.interrupt_config_editor_transition(); + } if self.overlay.is_none() && self @@ -1032,16 +1046,29 @@ impl App { && self.chat_widget.agents_navigation_key_available())) { self.open_agents_overview(app_server); - } else if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview { - self.chat_widget.handle_disconnected_view_key(*key); - if self - .chat_widget - .selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) - .is_none() + } else if self + .chat_widget + .active_view_accepts_input_when_disconnected() + || (self.reconnect.presentation == reconnect::ReconnectPresentation::Overview + && self + .chat_widget + .active_view_is(agents_overview::AGENTS_OVERVIEW_VIEW_ID)) + { + let activation_key = self.routed_key_activation.take(); + self.chat_widget + .handle_disconnected_view_key(*key, activation_key); + if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview + && self + .chat_widget + .selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) + .is_none() { self.reconnect.presentation = reconnect::ReconnectPresentation::Conversation; } } else { + if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview { + self.reconnect.presentation = reconnect::ReconnectPresentation::Conversation; + } self.chat_widget .handle_restricted_key(*key, RestrictedInputMode::Disconnected); } @@ -1090,12 +1117,15 @@ impl App { self.reset_backtrack_state(); } } - self.chat_widget.handle_paste(pasted); - if self.reconnect.offline + let restrict_disconnected_input = self.reconnect.offline && !self.chat_widget.keymap_contexts().is_warnings() && self.reconnect.presentation == reconnect::ReconnectPresentation::Conversation - { + && !self + .chat_widget + .active_view_accepts_input_when_disconnected(); + self.chat_widget.handle_paste(pasted); + if restrict_disconnected_input { self.chat_widget.handle_restricted_key( KeyEvent::new(KeyCode::Null, KeyModifiers::NONE), RestrictedInputMode::Disconnected, diff --git a/codex-rs/tui/src/app/config_panel.rs b/codex-rs/tui/src/app/config_panel.rs new file mode 100644 index 0000000000..f03ac38931 --- /dev/null +++ b/codex-rs/tui/src/app/config_panel.rs @@ -0,0 +1,169 @@ +//! Persists config-panel preferences, applies live-safe values, and handles notification previews. + +use super::*; +use crate::config_panel::ConfigPreference; +use crate::config_panel::ConfigToggle; +use crate::config_update::format_config_error; + +impl App { + pub(super) async fn save_config_preference( + &mut self, + tui: &mut tui::Tui, + preference: ConfigPreference, + ) { + let result = + ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) + .with_edits([preference.edit()]) + .apply() + .await; + if let Err(error) = result { + self.chat_widget + .refresh_config_panel(/*notice*/ Some(format!( + "Could not save: {}", + format_config_error(&error) + ))); + return; + } + + // TUI preferences are local, so remote sessions reload them from the launch directory. + let config_cwd = if crate::uses_remote_workspace_or_environment( + &self.app_server_target, + &self.environment_manager, + ) { + self.launch_cwd.clone() + } else { + self.chat_widget.config_ref().cwd.to_path_buf() + }; + let resolved = match self.rebuild_config_for_cwd(config_cwd).await { + Ok(config) => crate::local_settings::LocalSettings::from(&config), + Err(error) => { + self.chat_widget + .refresh_config_panel( + /*notice*/ Some(format!("Saved, but could not reload: {error}")), + ); + return; + } + }; + + let requires_restart = matches!( + preference, + ConfigPreference::Toggle { setting, .. } if setting.requires_restart() + ); + if !requires_restart { + preference.adopt(&mut self.local_settings.tui, &resolved.tui); + preference.adopt(&mut self.chat_widget.local_settings.tui, &resolved.tui); + } + if matches!( + preference, + ConfigPreference::Toggle { + setting: ConfigToggle::Notifications, + .. + } + ) && !ConfigToggle::Notifications.enabled(&resolved.tui) + { + self.chat_widget.pending_notification = None; + self.config_notification_test_pending = None; + } + self.local_settings.custom_notification_filters = resolved.custom_notification_filters; + self.chat_widget.local_settings.custom_notification_filters = + resolved.custom_notification_filters; + self.chat_widget.config_panel_tui = resolved.tui.clone(); + if matches!( + preference, + ConfigPreference::Toggle { + setting: ConfigToggle::StatusLineColors, + .. + } + ) { + self.refresh_status_line(); + } + if matches!(preference, ConfigPreference::NotificationCondition(_)) { + tui.set_notification_condition(self.local_settings.tui.notification_settings.condition); + } + let effective = match preference { + ConfigPreference::Toggle { setting, enabled } => { + setting.enabled(&resolved.tui) == enabled + } + ConfigPreference::CopyOnSelect(mode) => resolved.tui.copy_on_select == mode, + ConfigPreference::NotificationCondition(condition) => { + resolved.tui.notification_settings.condition == condition + } + ConfigPreference::RightClickPaste(mode) => resolved.tui.right_click_paste == mode, + ConfigPreference::SessionPickerLayout(mode) => { + resolved.tui.session_picker_view.unwrap_or_default() == mode + } + ConfigPreference::ResumeDirectory(mode) => resolved.tui.resume_cwd == mode, + }; + let notice = if !effective { + "Saved · another config layer or system preference overrides this value" + } else if requires_restart { + "Saved · restart Codex to apply" + } else if matches!( + preference, + ConfigPreference::Toggle { + setting: ConfigToggle::StartInVimMode, + .. + } + ) { + "Saved · applies to new composers" + } else { + "Saved" + }; + self.chat_widget + .refresh_config_panel(/*notice*/ Some(notice.to_string())); + tui.frame_requester().schedule_frame(); + } +} + +impl App { + pub(super) fn schedule_config_notification_test(&mut self) { + if self.config_notification_test_pending.is_some() { + self.chat_widget.refresh_config_panel(/*notice*/ Some( + "A test is already scheduled · switch to another app".into(), + )); + return; + } + self.config_notification_test_generation = self + .config_notification_test_generation + .wrapping_add(/*rhs*/ 1); + let generation = self.config_notification_test_generation; + self.config_notification_test_pending = Some(generation); + self.chat_widget.refresh_config_panel(/*notice*/ Some( + "Test scheduled in 5 seconds · switch to another app".into(), + )); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(/*secs*/ 5)).await; + tx.send(AppEvent::SendConfigTestNotification { generation }); + }); + } + + pub(super) fn send_config_notification_test(&mut self, tui: &mut tui::Tui, generation: u64) { + if self.config_notification_test_pending != Some(generation) { + return; + } + self.config_notification_test_pending = None; + let notice = if !tui.notify_test("Codex notification test") { + "Could not send the notification test · check the terminal connection" + } else if codex_terminal_detection::terminal_info().name + == codex_terminal_detection::TerminalName::Ghostty + && tui.is_terminal_focused() + { + "Test sent to terminal · Ghostty hides banners while focused; switch apps and retry" + } else { + "Test sent to terminal · delivery depends on terminal and system settings" + }; + if !self + .chat_widget + .refresh_config_panel(/*notice*/ Some(notice.into())) + { + self.chat_widget + .add_info_message(notice.into(), /*hint*/ None); + } + tui.frame_requester().schedule_frame(); + } +} + +#[cfg(test)] +#[path = "config_panel_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/config_panel_tests.rs b/codex-rs/tui/src/app/config_panel_tests.rs new file mode 100644 index 0000000000..0df17e7415 --- /dev/null +++ b/codex-rs/tui/src/app/config_panel_tests.rs @@ -0,0 +1,208 @@ +//! Config-panel persistence and delayed notification-preview behavior. + +use super::*; +use crate::AppServerTarget; +use crate::app::test_support::make_test_app; +use crate::app_event::AppEvent; +use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use crate::local_settings::LocalSettings; +use codex_protocol::config_types::TrustLevel; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn config_toggle_save_uses_host_config_while_offline() -> Result<()> { + let root = tempfile::tempdir()?; + let home = root.path().join("home"); + let local_cwd = root.path().join("local"); + let session_cwd = root.path().join("session"); + std::fs::create_dir_all(&home)?; + for (cwd, auto_recap) in [(&local_cwd, false), (&session_cwd, true)] { + std::fs::create_dir_all(cwd.join(".codex"))?; + std::fs::write( + cwd.join(".codex/config.toml"), + format!("[tui]\nauto_recap = {auto_recap}\n"), + )?; + } + let local_cwd = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(local_cwd)?)?; + let session_cwd = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(session_cwd)?)?; + ConfigEditsBuilder::new(&home) + .set_project_trust_level(local_cwd.to_path_buf(), TrustLevel::Trusted) + .set_project_trust_level(session_cwd.to_path_buf(), TrustLevel::Trusted) + .apply() + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(home) + .harness_overrides(ConfigOverrides { + cwd: Some(local_cwd.to_path_buf()), + ..Default::default() + }) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + assert!(!config.tui_auto_recap); + + let mut app = Box::pin(make_test_app()).await; + app.config = config.clone(); + app.local_settings = LocalSettings::from(&config); + app.launch_cwd = local_cwd.to_path_buf(); + app.config.cwd = session_cwd.clone(); + app.loader_overrides = loader_overrides; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:8765".to_string(), + auth_token: None, + }, + }; + app.reconnect.offline = true; + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut session_config = config; + session_config.cwd = session_cwd.clone(); + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + session_config, + /*initial_user_message*/ None, + ); + app.chat_widget = ChatWidget::new_with_app_event(init); + app.local_settings.tui.auto_recap = true; + app.chat_widget.local_settings.tui.auto_recap = true; + + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::AutomaticRecaps, + enabled: false, + }), + )) + .await?; + + assert_eq!( + ( + app.local_settings.tui.auto_recap, + app.chat_widget.local_settings.tui.auto_recap, + app.chat_widget.config_panel_tui.auto_recap, + ), + (true, true, false), + ); + let saved: toml::Value = toml::from_str(&std::fs::read_to_string( + &app.local_settings.user_config_path, + )?)?; + assert_eq!(saved["tui"]["auto_recap"].as_bool(), Some(false)); + assert_eq!(app.launch_cwd, local_cwd.to_path_buf()); + assert_eq!( + (&app.config.cwd, &app.chat_widget.config_ref().cwd), + (&session_cwd, &session_cwd), + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn notification_test_delay_rejects_cancelled_and_rescheduled_timers() -> Result<()> { + let (mut app, mut rx, _ops) = crate::app::tests::make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let notification_generation = |event| match event { + AppEvent::SendConfigTestNotification { generation } => generation, + _ => panic!("expected notification preview timer"), + }; + tui.set_notification_settings( + codex_config::types::NotificationMethod::Bel, + codex_config::types::NotificationCondition::Unfocused, + ); + app.chat_widget.open_config_panel(); + app.chat_widget.handle_key_event(KeyCode::Right.into()); + app.chat_widget.handle_key_event(KeyCode::Char('/').into()); + app.chat_widget.handle_paste("test notification".into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("test notification action"), + AppEvent::TestConfigNotification + )); + app.schedule_config_notification_test(); + app.schedule_config_notification_test(); + tokio::task::yield_now().await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(rx.try_recv().is_err()); + + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 4)).await; + assert!(rx.try_recv().is_err()); + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 1)).await; + tokio::task::yield_now().await; + let generation = notification_generation(rx.try_recv().expect("scheduled preview")); + assert!(rx.try_recv().is_err()); + + app.send_config_notification_test(&mut tui, generation); + let AppEvent::InsertHistoryCell(cell) = rx.try_recv()? else { + panic!("expected notification result in history"); + }; + assert!( + cell.display_lines(/*width*/ 120) + .iter() + .any(|line| line.to_string().starts_with("• Test sent to terminal · ")) + ); + + app.schedule_config_notification_test(); + let cancelled_generation = app + .config_notification_test_pending + .expect("pending preview"); + tokio::task::yield_now().await; + let (replacement, _, mut replacement_events, _) = make_chatwidget_manual_with_sender().await; + app.replace_chat_widget(replacement); + assert!(app.config_notification_test_pending.is_none()); + app.schedule_config_notification_test(); + let rescheduled_generation = app + .config_notification_test_pending + .expect("rescheduled preview"); + assert_ne!(cancelled_generation, rescheduled_generation); + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 5)).await; + tokio::task::yield_now().await; + let mut timer_generations = Vec::new(); + for _ in 0..2 { + timer_generations.push(notification_generation( + rx.try_recv().expect("preview timer"), + )); + } + timer_generations.sort_unstable(); + assert_eq!( + timer_generations, + vec![cancelled_generation, rescheduled_generation] + ); + + app.send_config_notification_test(&mut tui, cancelled_generation); + assert_eq!( + app.config_notification_test_pending, + Some(rescheduled_generation) + ); + assert!(rx.try_recv().is_err()); + assert!(replacement_events.try_recv().is_err()); + app.send_config_notification_test(&mut tui, rescheduled_generation); + assert!(matches!( + replacement_events.try_recv(), + Ok(AppEvent::InsertHistoryCell(_)) + )); + + app.schedule_config_notification_test(); + app.save_config_preference( + &mut tui, + ConfigPreference::Toggle { + setting: ConfigToggle::Notifications, + enabled: false, + }, + ) + .await; + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 5)).await; + tokio::task::yield_now().await; + let generation = notification_generation(rx.try_recv().expect("cancelled preview timer")); + app.send_config_notification_test(&mut tui, generation); + assert!(rx.try_recv().is_err()); + Ok(()) +} diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 5e8bf63ef3..76d0111f4a 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -474,12 +474,16 @@ impl App { self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All)?; self.local_settings = self.local_settings.reloaded(&config); self.refresh_server_version_overview_notice(CODEX_CLI_VERSION); - // Other preferences have runtime caches and are adopted when the widget is replaced. + // Copy preferences read directly by the live widget. Other preferences have runtime + // caches and are adopted when the widget is replaced. self.chat_widget .local_settings .tui .terminal_resize_reflow_max_rows = self.local_settings.tui.terminal_resize_reflow_max_rows; + self.chat_widget.local_settings.tui.session_picker_view = + self.local_settings.tui.session_picker_view; + self.chat_widget.local_settings.tui.resume_cwd = self.local_settings.tui.resume_cwd; self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) @@ -1925,10 +1929,13 @@ enabled = false } #[tokio::test] - async fn refresh_in_memory_config_from_disk_updates_resize_reflow_config() -> Result<()> { + async fn refresh_in_memory_config_from_disk_updates_live_widget_preferences() -> Result<()> { let mut app = make_test_app().await; let mut expected_widget_settings = app.chat_widget.local_settings.clone(); expected_widget_settings.tui.terminal_resize_reflow_max_rows = Some(9000); + expected_widget_settings.tui.session_picker_view = + Some(codex_config::types::SessionPickerViewMode::Comfortable); + expected_widget_settings.tui.resume_cwd = Some(codex_config::types::ResumeCwdMode::Current); let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); std::fs::write( @@ -1936,6 +1943,8 @@ enabled = false r#" [tui] terminal_resize_reflow_max_rows = 9000 +session_picker_view = "comfortable" +resume_cwd = "current" theme = "dracula" "#, )?; @@ -1946,6 +1955,10 @@ theme = "dracula" app.local_settings.terminal_resize_reflow().max_rows, crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(9000) ); + assert_eq!( + app.local_settings.tui.session_picker_view, + Some(codex_config::types::SessionPickerViewMode::Comfortable) + ); assert_eq!(app.local_settings.tui.theme.as_deref(), Some("dracula")); assert_eq!(app.chat_widget.local_settings, expected_widget_settings); Ok(()) @@ -2058,6 +2071,10 @@ theme = "dracula" app.chat_widget.local_settings.tui.theme.as_deref(), Some("dracula") ); + assert_eq!( + app.chat_widget.config_panel_tui.theme.as_deref(), + Some("dracula") + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 05f4a1bade..7808d3eaf7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -53,6 +53,23 @@ impl App { &event, AppEvent::OpenDaemonMenu | AppEvent::OpenWarnings + | AppEvent::StatusLineSetup { .. } + | AppEvent::StatusLineSetupCancelled + | AppEvent::TerminalTitleSetup { .. } + | AppEvent::TerminalTitleSetupPreview { .. } + | AppEvent::TerminalTitleSetupCancelled + | AppEvent::OpenConfigEditor(_) + | AppEvent::SyntaxThemeSelected { .. } + | AppEvent::SyntaxThemePreviewed + | AppEvent::OpenKeymapActionMenu { .. } + | AppEvent::OpenKeymapReplaceBindingMenu { .. } + | AppEvent::OpenKeymapCapture { .. } + | AppEvent::OpenKeymapDebug + | AppEvent::KeymapCaptured { .. } + | AppEvent::KeymapCleared { .. } + | AppEvent::TestConfigNotification + | AppEvent::SendConfigTestNotification { .. } + | AppEvent::SaveConfigPreference(_) | AppEvent::CopyWarning(_) | AppEvent::UpdateWarnings { .. } | AppEvent::CopySelection { .. } @@ -1332,8 +1349,12 @@ impl App { .handle_pet_selection_loaded(tui, request_id, pet_id, result) .await; } - AppEvent::ConfiguredPetLoaded { pet_id, result } => { - self.handle_configured_pet_loaded(tui, pet_id, result); + AppEvent::ConfiguredPetLoaded { + generation, + pet_id, + result, + } => { + self.handle_configured_pet_loaded(tui, generation, pet_id, result); } AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); @@ -3211,14 +3232,14 @@ impl App { self.local_settings.tui.status_line = Some(ids.clone()); self.local_settings.tui.status_line_use_colors = use_theme_colors; self.chat_widget.setup_status_line(items, use_theme_colors); + self.chat_widget.refresh_config_panel(Some("Saved".into())); } Err(err) => { let error = format_config_error(&err); tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection"); self.app_event_tx.send(AppEvent::FollowTranscript); - self.chat_widget.add_error_message(format!( - "Failed to save status line settings: {error}" - )); + let message = format!("Failed to save status line settings: {error}"); + self.chat_widget.report_config_save_error(message); } } } @@ -3240,6 +3261,7 @@ impl App { } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); + self.chat_widget.refresh_config_panel(/*notice*/ None); } AppEvent::TerminalTitleSetup { items } => { let ids = items.iter().map(ToString::to_string).collect::>(); @@ -3252,14 +3274,14 @@ impl App { Ok(()) => { self.local_settings.tui.terminal_title = Some(ids.clone()); self.chat_widget.setup_terminal_title(items); + self.chat_widget.refresh_config_panel(Some("Saved".into())); } Err(err) => { tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.revert_terminal_title_setup_preview(); - self.chat_widget.add_error_message(format!( - "Failed to save terminal title items: {err}" - )); + let message = format!("Failed to save terminal title items: {err}"); + self.chat_widget.report_config_save_error(message); } } } @@ -3268,6 +3290,36 @@ impl App { } AppEvent::TerminalTitleSetupCancelled => { self.chat_widget.cancel_terminal_title_setup(); + self.chat_widget.refresh_config_panel(/*notice*/ None); + } + AppEvent::OpenConfigEditor(handoff) => { + self.cancel_pending_key_chord(); + if let Err(err) = tui.discard_pending_input_before_interactive_screen() { + tracing::warn!(%err, "failed to discard input before opening config editor"); + self.chat_widget.config_editor_open_failed(format!( + "Failed to open setting because terminal input could not be cleared: {err}" + )); + } else { + let resume_current_availability = + super::resume_config::resume_current_directory_availability( + &self.app_server_target, + self.environment_manager.as_ref(), + self.runtime_working_directory_override + .as_deref() + .or(self.harness_overrides.cwd.as_deref()) + .or_else(|| app_server.remote_cwd_override()), + ); + self.chat_widget + .open_config_editor(handoff, resume_current_availability); + self.chat_widget.refresh_config_panel(/*notice*/ None); + } + } + AppEvent::TestConfigNotification => self.schedule_config_notification_test(), + AppEvent::SendConfigTestNotification { generation } => { + self.send_config_notification_test(tui, generation) + } + AppEvent::SaveConfigPreference(preference) => { + self.save_config_preference(tui, preference).await; } AppEvent::PersistAgentsOverviewGrouping(grouping) => { self.persist_agents_overview_grouping(grouping).await; @@ -3291,6 +3343,7 @@ impl App { crate::render::highlight::set_syntax_theme(theme); } self.sync_tui_theme_selection(name); + self.chat_widget.refresh_config_panel(Some("Saved".into())); self.refresh_status_line(); tui.frame_requester().schedule_frame(); } @@ -3299,8 +3352,8 @@ impl App { self.refresh_status_line(); tracing::error!(error = %err, "failed to persist theme selection"); self.app_event_tx.send(AppEvent::FollowTranscript); - self.chat_widget - .add_error_message(format!("Failed to save theme: {err}")); + let message = format!("Failed to save theme: {err}"); + self.chat_widget.report_config_save_error(message); } } } @@ -3496,7 +3549,7 @@ impl App { tracing::error!(error = %err, "failed to persist keymap binding"); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget - .add_error_message(format!("Failed to save shortcut: {err}")); + .report_config_save_error(format!("Failed to save shortcut: {err}")); } } } @@ -3557,7 +3610,7 @@ impl App { tracing::error!(error = %err, "failed to clear keymap binding"); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget - .add_error_message(format!("Failed to remove shortcut: {err}")); + .report_config_save_error(format!("Failed to remove shortcut: {err}")); } } } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index c1b0a76d9e..877b329693 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -69,9 +69,20 @@ impl App { tui: &mut tui::Tui, key_event: KeyEvent, ) -> Option { + self.routed_key_activation = None; if self.chat_widget.take_key_chord_reset() { self.cancel_pending_key_chord(); } + if self + .chat_widget + .consume_view_transition_key_event(key_event) + { + return None; + } + // Pending config handoffs must see the physical key before a chord or global shortcut + // consumes it. + self.chat_widget + .observe_config_editor_transition_key_event(key_event); self.transcript_view.set_keymap_bindings(&self.keymap); if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { overlay.set_keymap_bindings(&self.keymap); @@ -166,6 +177,8 @@ impl App { } crate::keymap::KeyChordMatch::Completed(dispatch_event) => { self.set_key_chord_hint_override(/*items*/ None); + // Keep the real key so the newly opened config editor ignores it until release. + self.routed_key_activation = Some(KeyBinding::from_event(key_event)); Some(dispatch_event) } crate::keymap::KeyChordMatch::Cancelled => { @@ -373,6 +386,7 @@ impl App { app_server: &mut AppServerSession, key_event: KeyEvent, ) { + let activation_key = self.routed_key_activation.take(); if self.chat_widget.fork_in_progress { return; } @@ -615,7 +629,9 @@ impl App { } else if self.should_reject_side_backtrack_esc(key_event) { self.reject_side_backtrack_esc(); } else { - let action = self.chat_widget.handle_key_event(key_event); + let action = self + .chat_widget + .handle_key_event_with_activation_key(key_event, activation_key); self.handle_clipboard_key_action(tui, action); } return; @@ -650,11 +666,14 @@ impl App { self.reset_backtrack_state(); } } - let action = self.chat_widget.handle_key_event(key_event); + let action = self + .chat_widget + .handle_key_event_with_activation_key(key_event, activation_key); self.handle_clipboard_key_action(tui, action); } _ => { - self.chat_widget.handle_key_event(key_event); + self.chat_widget + .handle_key_event_with_activation_key(key_event, activation_key); } }; } diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 555f4186bf..744c2ed4ce 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -48,6 +48,8 @@ impl App { view.copy_on_select = self .local_settings .copy_on_select(&codex_terminal_detection::terminal_info()); + view.copy_on_select_clear_selection = + self.local_settings.tui.copy_on_select_clear_selection; view.set_keymap_bindings(&self.keymap); view.set_presentation(view.is_detailed(), chat_widget.history_render_mode()); let active_key = chat_widget.active_cell_transcript_key(); diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs index a21fe1a7f1..4253fe9563 100644 --- a/codex-rs/tui/src/app/pets.rs +++ b/codex-rs/tui/src/app/pets.rs @@ -175,23 +175,15 @@ impl App { pub(super) fn handle_configured_pet_loaded( &mut self, tui: &mut tui::Tui, + generation: crate::chatwidget::ConfiguredPetLoadGeneration, pet_id: String, result: Result, String>, ) { - if self.local_settings.tui.pet.as_deref() != Some(pet_id.as_str()) { - return; - } - - match result { - Ok(ambient_pet) => { - self.chat_widget - .set_tui_pet_loaded(Some(pet_id), ambient_pet); - tui.frame_requester().schedule_frame(); - } - Err(err) => { - self.chat_widget - .add_warning_message(format!("Failed to load configured pet: {err}")); - } + if self + .chat_widget + .finish_configured_pet_load(generation, pet_id, result) + { + tui.frame_requester().schedule_frame(); } } } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 39336fade6..7c44213105 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -240,10 +240,15 @@ impl App { } ReconnectPresentation::Overview } else { - self.chat_widget.handle_restricted_key( - KeyEvent::new(KeyCode::Null, KeyModifiers::NONE), - RestrictedInputMode::Disconnected, - ); + if !self + .chat_widget + .active_view_accepts_input_when_disconnected() + { + self.chat_widget.handle_restricted_key( + KeyEvent::new(KeyCode::Null, KeyModifiers::NONE), + RestrictedInputMode::Disconnected, + ); + } ReconnectPresentation::Conversation }; self.chat_widget.pause_for_disconnect(); @@ -264,6 +269,12 @@ impl App { connected: Reconnected, client_version: &str, ) -> Result<()> { + // Keep input paused while the disconnected view is replaced so its keys cannot reach the + // restored composer. The final drain below drops anything typed during the handoff. + let discard_replaced_view_input = self.chat_widget.cancel_active_disconnected_view(); + if discard_replaced_view_input { + tui.pause_events(); + } let Reconnected { mut session, bootstrap, @@ -285,6 +296,8 @@ impl App { let (tx, rx) = mpsc::unbounded_channel(); self.app_event_tx = AppEventSender::new(tx); *app_event_rx = rx; + // The old timer targets the discarded channel, so a new preview can be scheduled. + self.config_notification_test_pending = None; { let mut state = self .agents_overview @@ -442,9 +455,16 @@ impl App { self.active_thread_id = Some(id); self.active_thread_rx = Some(receiver); self.recap.seed_from_turns(&snapshot.turns, Instant::now()); - self.render_thread_snapshot( + if let Err(err) = self.render_thread_snapshot( tui, app_server, id, snapshot, /*resume_restored_queue*/ false, - )?; + ) { + if discard_replaced_view_input + && let Err(drain_err) = tui.discard_pending_input_before_interactive_screen() + { + tracing::warn!(%drain_err, "failed to discard input after reconnecting"); + } + return Err(err); + } self.config = self.chat_widget.config_ref().clone(); self.refresh_pending_thread_approvals().await; if self.thread_unavailable(id) && !self.chat_widget.is_external_writer_view() { @@ -545,6 +565,11 @@ impl App { self.chat_widget.add_server_version_warning(notice); } } + if discard_replaced_view_input + && let Err(err) = tui.discard_pending_input_before_interactive_screen() + { + tracing::warn!(%err, "failed to discard input after reconnecting"); + } Ok(()) } } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 30add17ec1..1cfe074c07 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -10,6 +10,21 @@ use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; use codex_config::types::ResumeCwdMode; +/// Marks "Current directory" unavailable for remote sessions without a local cwd override. +pub(super) fn resume_current_directory_availability( + app_server_target: &AppServerTarget, + environment_manager: &EnvironmentManager, + cwd_override: Option<&Path>, +) -> crate::config_panel::ResumeCurrentAvailability { + if crate::uses_remote_workspace_or_environment(app_server_target, environment_manager) + && cwd_override.is_none() + { + crate::config_panel::ResumeCurrentAvailability::RequiresCwdOverride + } else { + crate::config_panel::ResumeCurrentAvailability::Available + } +} + impl App { pub(super) async fn resume_config_for_target( &mut self, diff --git a/codex-rs/tui/src/app/right_click_paste.rs b/codex-rs/tui/src/app/right_click_paste.rs index 68475c76be..7d32990a2d 100644 --- a/codex-rs/tui/src/app/right_click_paste.rs +++ b/codex-rs/tui/src/app/right_click_paste.rs @@ -1,9 +1,11 @@ //! Right-click CLIPBOARD and local X11 middle-click PRIMARY fallback for an editable composer. +//! The policy also reports why a configured mode is unavailable in this terminal. //! Existing selection handlers run first. Only a draw may deliver a read, so real input //! invalidates pending paste before completion and is never replaced by clipboard text. use super::*; use crate::clipboard_copy::worker::PasteSource; +use crate::transcript_mode::TranscriptMode; use crate::tui::VscodeDetection; use codex_config::types::RightClickPaste; use crossterm::event::MouseButton; @@ -17,7 +19,7 @@ pub(super) struct PendingPaste { _request: Arc<()>, } -pub(super) struct PasteEnvironment { +pub(crate) struct PasteEnvironment { pub(super) primary: bool, pub(super) platform_default: bool, pub(super) ssh: bool, @@ -26,7 +28,7 @@ pub(super) struct PasteEnvironment { } impl PasteEnvironment { - pub(super) fn detect() -> Self { + pub(crate) fn detect() -> Self { Self { primary: crate::clipboard_copy::primary::available(), platform_default: cfg!(any(target_os = "windows", target_os = "linux")), @@ -36,16 +38,39 @@ impl PasteEnvironment { } } - pub(super) fn allows(&self, mode: RightClickPaste) -> bool { - if self.ssh || self.vscode == VscodeDetection::VsCode { - return false; + pub(super) fn allows(&self, mode: RightClickPaste, transcript_mode: TranscriptMode) -> bool { + self.status(mode, transcript_mode) + .unwrap_or(/*default*/ false) + } + + /// Return the effective state or why this terminal must handle paste itself. + pub(crate) fn status( + &self, + mode: RightClickPaste, + transcript_mode: TranscriptMode, + ) -> Result { + if mode == RightClickPaste::Off { + return Ok(false); + } + if !transcript_mode.is_owned() { + return Err("Unavailable in inline mode. Use your terminal's paste command."); + } + if self.ssh { + return Err("Unavailable over SSH. Use your terminal's paste command."); + } + if self.vscode == VscodeDetection::VsCode { + return Err("VS Code handles pasting. Use its terminal paste command."); + } + if mode == RightClickPaste::Auto && self.wsl && self.vscode == VscodeDetection::Unknown { + return Err( + "Auto cannot identify this WSL terminal. Use On or your terminal's paste command.", + ); } match mode { - RightClickPaste::Off => false, - RightClickPaste::On => !cfg!(target_os = "android"), - RightClickPaste::Auto => { - self.platform_default && !(self.wsl && self.vscode == VscodeDetection::Unknown) - } + RightClickPaste::Off => Ok(false), + RightClickPaste::On if cfg!(target_os = "android") => Err("Unavailable on Android"), + RightClickPaste::On => Ok(true), + RightClickPaste::Auto => Ok(self.platform_default), } } } @@ -65,9 +90,10 @@ impl App { .borrow() .view .has_selection_range() - && self - .right_click_paste_environment - .allows(self.local_settings.tui.right_click_paste) + && self.right_click_paste_environment.allows( + self.local_settings.tui.right_click_paste, + self.local_settings.transcript_mode, + ) } PasteSource::Primary => self.right_click_paste_environment.primary, }; diff --git a/codex-rs/tui/src/app/right_click_paste_tests.rs b/codex-rs/tui/src/app/right_click_paste_tests.rs index 21cb0477cb..ff56e84801 100644 --- a/codex-rs/tui/src/app/right_click_paste_tests.rs +++ b/codex-rs/tui/src/app/right_click_paste_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::transcript_mode::TranscriptMode; use pretty_assertions::assert_eq; #[test] @@ -6,13 +7,38 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() { use VscodeDetection::Other; use VscodeDetection::Unknown; use VscodeDetection::VsCode; - for (platform_default, ssh, wsl, vscode, expected) in [ - (true, false, false, Other, [false, true, true]), - (false, false, false, Other, [false, true, false]), - (true, true, false, Other, [false, false, false]), - (true, false, false, VsCode, [false, false, false]), - (true, false, true, Unknown, [false, true, false]), - (true, false, true, Other, [false, true, true]), + const SSH_UNAVAILABLE: &str = "Unavailable over SSH. Use your terminal's paste command."; + const VSCODE_UNAVAILABLE: &str = "VS Code handles pasting. Use its terminal paste command."; + const WSL_AUTO_UNAVAILABLE: &str = + "Auto cannot identify this WSL terminal. Use On or your terminal's paste command."; + const INLINE_UNAVAILABLE: &str = + "Unavailable in inline mode. Use your terminal's paste command."; + let on = if cfg!(target_os = "android") { + Err("Unavailable on Android") + } else { + Ok(true) + }; + for (platform_default, ssh, wsl, vscode, on_status, auto_status) in [ + (true, false, false, Other, on, Ok(true)), + (false, false, false, Other, on, Ok(false)), + ( + true, + true, + false, + Other, + Err(SSH_UNAVAILABLE), + Err(SSH_UNAVAILABLE), + ), + ( + true, + false, + false, + VsCode, + Err(VSCODE_UNAVAILABLE), + Err(VSCODE_UNAVAILABLE), + ), + (true, false, true, Unknown, on, Err(WSL_AUTO_UNAVAILABLE)), + (true, false, true, Other, on, Ok(true)), ] { let env = PasteEnvironment { primary: false, @@ -27,10 +53,23 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() { RightClickPaste::On, RightClickPaste::Auto ] - .map(|mode| env.allows(mode)), - expected + .map(|mode| env.status(mode, TranscriptMode::Owned)), + [Ok(false), on_status, auto_status] ); } + + let env = PasteEnvironment { + primary: false, + platform_default: true, + ssh: false, + wsl: false, + vscode: Other, + }; + assert_eq!( + [RightClickPaste::Off, RightClickPaste::On] + .map(|mode| env.status(mode, TranscriptMode::Terminal)), + [Ok(false), Err(INLINE_UNAVAILABLE)] + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 60331a8274..0499b7b5d2 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -492,6 +492,7 @@ impl App { pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { chat_widget.fork_in_progress = self.chat_widget.fork_in_progress; self.pending_right_click_paste = None; + self.config_notification_test_pending = None; if !self.chat_widget.realtime_conversation_is_running() { self.retain_realtime_replay_state_before_replace(); } @@ -504,6 +505,7 @@ impl App { if chat_widget.last_terminal_title.is_none() { chat_widget.last_terminal_title = previous_terminal_title; } + chat_widget.config_panel_tui = self.chat_widget.config_panel_tui.clone(); chat_widget.remote_connection = self.chat_widget.remote_connection.clone(); chat_widget.snapshot_local_images = self.app_server_target.uses_remote_workspace(); chat_widget.set_local_worktree_operations(self.chat_widget.local_worktree_operations); diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index a684a56c0c..b9a876e408 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -880,6 +880,8 @@ See the Codex keymap documentation for supported actions and examples." agents_overview::AgentsOverviewState::new(local_settings.tui.agents_overview_grouping); let mut app = Self { feature_write_lock: Arc::default(), + config_notification_test_generation: 0, + config_notification_test_pending: None, model_catalog, session_telemetry: session_telemetry.clone(), app_event_tx, @@ -902,6 +904,7 @@ See the Codex keymap documentation for supported actions and examples." enhanced_keys_supported, keymap: runtime_keymap, key_chord_matcher: KeyChordMatcher::default(), + routed_key_activation: None, transcript_cells: Vec::new(), native_history: Default::default(), turn_tips: Default::default(), @@ -995,9 +998,10 @@ See the Codex keymap documentation for supported actions and examples." }; if tui.is_owned_screen() && app.right_click_paste_environment.wsl - && app - .right_click_paste_environment - .allows(app.local_settings.tui.right_click_paste) + && app.right_click_paste_environment.allows( + app.local_settings.tui.right_click_paste, + app.local_settings.transcript_mode, + ) { // Prewarm WSL interop off the UI thread, without accessing clipboard contents. let _ = tui.clipboard.warm_text_reader(tui.frame_requester()); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index ae588ce001..ecbdb9cb7f 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -22,6 +22,8 @@ pub(crate) async fn make_test_app() -> Box { Box::new(App { feature_write_lock: Arc::default(), + config_notification_test_generation: 0, + config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, app_event_tx, @@ -59,6 +61,7 @@ pub(crate) async fn make_test_app() -> Box { enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), + routed_key_activation: None, commit_animation: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c1560772ec..89613d939c 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2039,6 +2039,11 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window() /*use_theme_colors*/ true, ); + assert_eq!( + app.chat_widget.config_panel_tui.status_line, + app.chat_widget.local_settings.tui.status_line + ); + assert!(app.chat_widget.config_panel_tui.status_line_use_colors); assert_eq!(app.chat_widget.status_line_text(), None); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( @@ -6193,6 +6198,8 @@ async fn make_test_app() -> Box { Box::new(App { feature_write_lock: Arc::default(), + config_notification_test_generation: 0, + config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, app_event_tx, @@ -6229,6 +6236,7 @@ async fn make_test_app() -> Box { enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), + routed_key_activation: None, commit_animation: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -6314,6 +6322,8 @@ pub(super) async fn make_test_app_with_channels() -> ( ( Box::new(App { feature_write_lock: Arc::default(), + config_notification_test_generation: 0, + config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, app_event_tx, @@ -6350,6 +6360,7 @@ pub(super) async fn make_test_app_with_channels() -> ( enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), + routed_key_activation: None, commit_animation: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index 2daf07b457..7828068a5b 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::app_server_session::ThreadParamsMode; +use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCRequest; @@ -247,6 +248,77 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< Ok(()) } +#[tokio::test] +async fn disconnected_config_panel_keeps_input() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget + .restore_user_message_to_composer("/config".into()); + + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::Enter.into())) + .await?; + assert!(app.chat_widget.has_active_view()); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + assert!(app.begin_reconnect()); + assert!(app.chat_widget.has_active_view()); + + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyCode::Char('/').into()), + ) + .await?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyCode::Char('t').into()), + ) + .await?; + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("heme".into())) + .await?; + + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("\n Search › theme\n")); + + assert_eq!(app.chat_widget.local_settings.tui.terminal_title, None); + app.chat_widget + .preview_terminal_title(vec![TerminalTitleItem::Project]); + app.chat_widget.show_selection_view(SelectionViewParams { + allow_input_when_disconnected: true, + on_cancel: Some(Box::new(|tx| { + tx.send(AppEvent::TerminalTitleSetupCancelled) + })), + ..SelectionViewParams::picker() + }); + assert!(matches!( + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + ) + .await?, + AppRunControl::Continue + )); + assert_eq!(app.chat_widget.local_settings.tui.terminal_title, None); + assert!(matches!( + events.try_recv(), + Ok(AppEvent::TerminalTitleSetupCancelled) + )); + + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::Esc.into())) + .await?; + app.chat_widget + .open_model_popup_with_presets(crate::test_support::TEST_MODEL_PRESETS.clone()); + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::Enter.into())) + .await?; + assert!(!app.chat_widget.has_active_view()); + assert!(events.try_recv().is_err()); + session.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn remote_disconnect_retires_voice_before_reconnecting() -> Result<()> { let (mut app, _events, _ops) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 35d8d52cf6..6c1321ad52 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -4,6 +4,7 @@ use super::Result; use super::RuntimeKeymap; use super::TuiEvent; use super::make_test_app; +use super::make_test_app_with_channels; use super::start_config_write_test_app_server; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -22,6 +23,7 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; @@ -190,6 +192,107 @@ async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<( Ok(()) } +#[tokio::test] +async fn config_editor_chord_preserves_physical_completion_key_until_release() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut config = TuiKeymap::default(); + config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec( + "enter enter".to_string(), + ))); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + app.reconnect.offline = true; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.open_config_panel(); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('/').into(), + ) + .await?; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Paste("terminal title".to_string()), + ) + .await?; + + for _ in 0..2 { + press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?; + } + let handoff = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + crate::app_event::AppEvent::OpenConfigEditor(handoff) => Some(handoff), + _ => None, + }) + .expect("config editor handoff"); + assert_eq!( + handoff.activation_key, + crate::key_hint::plain(KeyCode::Enter) + ); + assert!(handoff.active_at().is_some()); + + app.chat_widget.open_config_editor( + handoff, + crate::config_panel::ResumeCurrentAvailability::Available, + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Configure Terminal Title") + ); + let repeat = KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(repeat)) + .await?; + let release = + KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(release)) + .await?; + for _ in 0..2 { + press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?; + } + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, crate::app_event::AppEvent::TerminalTitleSetup { .. })) + ); + Ok(()) +} + +#[tokio::test] +async fn queued_config_handoff_observes_a_consumed_chord_prefix() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut config = TuiKeymap::default(); + config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-x j".to_string()))); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.open_config_panel(); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Paste("terminal title".to_string()), + ) + .await?; + + press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?; + let handoff = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + crate::app_event::AppEvent::OpenConfigEditor(handoff) => Some(handoff), + _ => None, + }) + .expect("config editor handoff"); + assert!(handoff.active_at().is_some()); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert_eq!(handoff.active_at(), None); + Ok(()) +} + #[cfg(unix)] #[tokio::test] async fn legacy_terminal_recovers_vim_escape_before_normal_commands() -> Result<()> { diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 1fb4b8b5bf..918e7a73a6 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -145,7 +145,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ); view.handle_key_event(KeyCode::Char('r').into()); view.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - view.handle_paste("Keep this task draft".into()); + view.handle_paste("Keep this task draft!".into()); app.agents_overview.visible_thread_ids = view.thread_ids(); app.chat_widget.show_bottom_pane_view(Box::new(view)); let draft = |app: &App| { @@ -295,16 +295,8 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ); assert_eq!(app.agents_overview.visible_thread_ids.len(), 2); let mut tui = crate::tui::test_support::make_test_tui()?; - app.handle_tui_event( - &mut tui, - &mut session, - TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), - ) - .await?; - app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) - .await?; assert_eq!(draft(&app), "Keep this task draft!"); - if previous_thread.is_none() { + if previous_thread.is_none() && !overview_initialized { assert_snapshot!( "daemon_command_center_reconnecting", render_bottom_popup(&app.chat_widget, /*width*/ 100) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index a6ab4f8983..f962f3ba8e 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -166,13 +166,21 @@ impl App { return; } let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript( - self.transcript_cells.clone(), - self.keymap.pager.clone(), - self.local_settings - .copy_on_select(&codex_terminal_detection::terminal_info()), - self.local_settings.tui.mouse_scroll_speed.unwrap_or(1.0), - )); + self.overlay = Some( + Overlay::new_transcript( + self.transcript_cells.clone(), + self.keymap.pager.clone(), + self.local_settings + .copy_on_select(&codex_terminal_detection::terminal_info()), + self.local_settings + .tui + .mouse_scroll_speed + .unwrap_or(/*default*/ 1.0), + ) + .with_copy_on_select_clear_selection( + self.local_settings.tui.copy_on_select_clear_selection, + ), + ); if self.scrollback_has_older_history && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4422be5cd7..44f4e8a82a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -54,6 +54,7 @@ use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::AstraModelPickerAction; +use crate::chatwidget::ConfiguredPetLoadGeneration; use crate::chatwidget::ConnectorScopeGeneration; use crate::chatwidget::ThreadUsageOutcome; use crate::chatwidget::UserMessage; @@ -906,6 +907,7 @@ pub(crate) enum AppEvent { /// Result of restoring the configured ambient pet during startup. ConfiguredPetLoaded { + generation: ConfiguredPetLoadGeneration, pet_id: String, result: Result, String>, }, @@ -1622,6 +1624,20 @@ pub(crate) enum AppEvent { /// Remember the Command Center grouping across launches. PersistAgentsOverviewGrouping(codex_config::types::AgentsOverviewGrouping), + /// Open an existing editor above the configuration panel. + OpenConfigEditor(crate::config_panel::ConfigEditorHandoff), + + /// Schedule a notification preview with time to switch away from the terminal. + TestConfigNotification, + + /// Send the matching scheduled preview regardless of the configured focus condition. + SendConfigTestNotification { + generation: u64, + }, + + /// Requests saving a local `/config` preference and refreshing its effective value. + SaveConfigPreference(crate::config_panel::ConfigPreference), + /// Save the transcript renderer preference for the next launch only. FullscreenTranscriptSelected { enabled: bool, diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index fe5b341c1f..51ed1d7112 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,6 +1,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::McpServerElicitationFormRequest; +use crate::key_hint::KeyBinding; use crate::keymap::KeymapContextSet; use crate::render::renderable::Renderable; use codex_app_server_protocol::ToolRequestUserInputParams; @@ -31,15 +32,52 @@ pub(crate) trait BottomPaneView: Renderable { ViewPresentation::Inline } + /// Refresh an open configuration panel, including one below a child editor. + /// Returns whether this view contains a configuration panel. + fn update_config_panel(&mut self, _update: &crate::config_panel::ConfigPanelUpdate) -> bool { + false + } + + /// Acknowledge that a queued configuration editor has opened. + fn config_editor_opened(&mut self) {} + + /// Mark a queued editor activation inactive after a non-key interaction. + fn interrupt_config_editor_transition(&mut self) {} + /// Handle a key event while the view is active. A redraw is always /// scheduled after this call. fn handle_key_event(&mut self, _key_event: KeyEvent) {} + /// Handle a configured action while preserving the physical key that triggered it. + fn handle_key_event_with_activation_key( + &mut self, + key_event: KeyEvent, + _activation_key: Option, + ) { + self.handle_key_event(key_event); + } + + /// Observe a press or repeat while another view is on top without handling it as input. + fn observe_key_event_while_covered( + &mut self, + _key_event: KeyEvent, + _activation_key: KeyBinding, + ) { + } + + /// Observe key releases that the bottom pane otherwise filters before view dispatch. + fn handle_key_release(&mut self, _key_event: KeyEvent) {} + /// Return the keymap contexts whose handlers are active in this view. fn keymap_contexts(&self) -> KeymapContextSet { KeymapContextSet::default() } + /// Whether this local-only view can continue handling input while disconnected. + fn accepts_input_when_disconnected(&self) -> bool { + false + } + /// Return `true` if the view has finished and should be removed. fn is_complete(&self) -> bool { false diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 56beebc795..b832d659e1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -498,6 +498,7 @@ fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool { | SlashCommand::MultiAgents | SlashCommand::Vim | SlashCommand::Keymap + | SlashCommand::Config | SlashCommand::ElevateSandbox | SlashCommand::Experimental | SlashCommand::Memories diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index ac64a4ea6a..703a8d0270 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -86,6 +86,7 @@ impl ChatComposer { .filter(|_| text.trim().lines().count() == 1); if matches!(command, Some(SlashCommandItem::Builtin(command)) if command == SlashCommand::Warnings + || command == SlashCommand::Config || mode == RestrictedInputMode::UnavailableThread && command.available_when_thread_unavailable()) { return self diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs index d1d7cd7bce..66dc657c71 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs @@ -156,6 +156,21 @@ fn unavailable_thread_dispatches_recovery_and_local_commands() { } } +#[test] +fn disconnected_input_dispatches_local_config_command() { + let (mut composer, _) = new_test_composer(); + composer.set_text_content("/config".into(), Vec::new(), Vec::new()); + + assert_eq!( + composer.handle_restricted_key( + KeyEvent::from(KeyCode::Enter), + RestrictedInputMode::Disconnected, + ), + InputResult::Command(SlashCommand::Config), + ); + assert_eq!(composer.current_text(), ""); +} + #[test] fn unavailable_thread_dispatches_inline_commands_with_configured_submit_key() { for (text, command, args) in [ diff --git a/codex-rs/tui/src/bottom_pane/config_panel.rs b/codex-rs/tui/src/bottom_pane/config_panel.rs new file mode 100644 index 0000000000..02134151f1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/config_panel.rs @@ -0,0 +1,47 @@ +//! Refresh configuration panels and coordinate their editor handoffs. + +use super::BottomPane; +use crate::config_panel::ConfigPanelUpdate; +use crate::key_hint::KeyBinding; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; + +impl BottomPane { + pub(crate) fn update_config_panel(&mut self, update: &ConfigPanelUpdate) -> bool { + let mut updated = false; + for view in &mut self.view_stack { + updated |= view.update_config_panel(update); + } + self.request_redraw(); + updated + } + + pub(crate) fn config_editor_opened(&mut self) { + for view in &mut self.view_stack { + view.config_editor_opened(); + } + } + + pub(crate) fn interrupt_config_editor_transition(&mut self) { + self.view_transition_key_guard = None; + for view in &mut self.view_stack { + view.interrupt_config_editor_transition(); + } + } + + pub(crate) fn observe_config_editor_transition_key_event(&mut self, key: KeyEvent) { + match key.kind { + KeyEventKind::Press | KeyEventKind::Repeat => { + let activation_key = KeyBinding::from_event(key); + for view in &mut self.view_stack { + view.observe_key_event_while_covered(key, activation_key); + } + } + KeyEventKind::Release => { + for view in &mut self.view_stack { + view.handle_key_release(key); + } + } + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 0ee6a3956d..afd1686038 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -52,6 +52,7 @@ pub(crate) use super::selection_row_layout::SelectionDescriptionLayout; use super::selection_tabs::SelectionTab; use super::selection_tabs::render_tab_bar; use super::selection_tabs::tab_bar_height; +use std::cell::Cell; use unicode_width::UnicodeWidthStr; /// Minimum list width (in content columns) required before the side-by-side @@ -198,6 +199,8 @@ pub(crate) struct SelectionViewParams { pub max_visible_rows: usize, /// Keep the result area at its configured row budget when filtering shrinks it. pub reserve_result_rows: bool, + /// Whether this picker can continue handling input without the app server. + pub allow_input_when_disconnected: bool, pub view_id: Option<&'static str>, pub title: Option, pub subtitle: Option, @@ -210,6 +213,7 @@ pub(crate) struct SelectionViewParams { pub is_searchable: bool, /// Optional non-text key that can toggle a row while printable keys edit the search query. pub search_toggle_key: Option, + pub search_mode: SearchMode, pub search_placeholder: Option, pub col_width_mode: ColumnWidthMode, pub row_display: SelectionRowDisplay, @@ -222,6 +226,8 @@ pub(crate) struct SelectionViewParams { /// Blank rows after the header; defaults to one. Inline banners use zero. pub header_gap: u16, pub initial_selected_idx: Option, + /// Restore a filter together with its actual selected item after a source refresh. + pub initial_search_query: Option, /// Rich content rendered beside (wide terminals) or below (narrow terminals) /// the list items, inside the bordered menu surface. Used by the theme picker @@ -259,6 +265,7 @@ impl Default for SelectionViewParams { picker_surface: PickerSurface::default(), max_visible_rows: MAX_POPUP_ROWS, reserve_result_rows: false, + allow_input_when_disconnected: false, presentation: super::ViewPresentation::Inline, view_id: None, title: None, @@ -271,6 +278,7 @@ impl Default for SelectionViewParams { initial_tab_id: None, is_searchable: false, search_toggle_key: None, + search_mode: SearchMode::default(), search_placeholder: None, col_width_mode: ColumnWidthMode::AutoVisible, row_display: SelectionRowDisplay::Wrapped, @@ -280,6 +288,7 @@ impl Default for SelectionViewParams { header_view_all_hint: None, header_gap: 1, initial_selected_idx: None, + initial_search_query: None, side_content: Box::new(()), side_content_width: SideContentWidth::default(), side_content_min_width: 0, @@ -292,6 +301,14 @@ impl Default for SelectionViewParams { } } +/// Controls whether a searchable list starts in search mode or waits for a shortcut. +#[derive(Clone, Copy, Default)] +pub(crate) enum SearchMode { + #[default] + Immediate, + OnDemand(KeyBinding), +} + /// Runtime state for rendering and interacting with a list-based selection popup. /// /// This type is the single authority for filtered index mapping between @@ -302,6 +319,7 @@ pub(crate) struct ListSelectionView { picker_surface: PickerSurface, max_visible_rows: usize, reserve_result_rows: bool, + allow_input_when_disconnected: bool, view_id: Option<&'static str>, footer_note: Option>, footer_hint: Option>, @@ -315,6 +333,8 @@ pub(crate) struct ListSelectionView { pub(super) app_event_tx: AppEventSender, is_searchable: bool, search_toggle_key: Option, + search_mode: SearchMode, + search_active: bool, search_query: String, search_placeholder: Option, col_width_mode: ColumnWidthMode, @@ -323,7 +343,8 @@ pub(crate) struct ListSelectionView { name_column_width: Option, filtered_indices: Vec, last_selected_actual_idx: Option, - rendered_item_count: std::cell::Cell, + rendered_item_count: Cell, + search_cursor: Cell>, header: Box, header_view_all_hint: Option, header_gap: u16, @@ -438,10 +459,14 @@ impl ListSelectionView { Some(active_tab_idx.unwrap_or(0)) }; let has_initial_selected_idx = params.initial_selected_idx.is_some(); + let search_active = matches!(params.search_mode, SearchMode::Immediate) + || params.initial_search_query.is_some(); + let search_query = params.initial_search_query.unwrap_or_default(); let mut s = Self { picker_surface: params.picker_surface, max_visible_rows: params.max_visible_rows.max(/*other*/ 1), reserve_result_rows: params.reserve_result_rows, + allow_input_when_disconnected: params.allow_input_when_disconnected, presentation: params.presentation, view_id: params.view_id, footer_note: params.footer_note, @@ -456,7 +481,9 @@ impl ListSelectionView { app_event_tx, is_searchable: params.is_searchable, search_toggle_key: params.search_toggle_key, - search_query: String::new(), + search_mode: params.search_mode, + search_active, + search_query, search_placeholder: if params.is_searchable { params.search_placeholder } else { @@ -468,7 +495,8 @@ impl ListSelectionView { name_column_width: params.name_column_width, filtered_indices: Vec::new(), last_selected_actual_idx: None, - rendered_item_count: std::cell::Cell::new(0), + rendered_item_count: Cell::new(/*value*/ 0), + search_cursor: Cell::new(/*value*/ None), header, header_view_all_hint: params.header_view_all_hint, header_gap: params.header_gap, @@ -917,8 +945,29 @@ impl ListSelectionView { } } + /// Preserve a searchable picker's filter when its parent rebuilds the view. + pub(crate) fn search_query(&self) -> Option<&str> { + self.search_field_visible() + .then_some(self.search_query.as_str()) + } + + fn search_field_visible(&self) -> bool { + self.is_searchable && self.search_active + } + + fn on_demand_search_active(&self) -> bool { + self.search_active && matches!(self.search_mode, SearchMode::OnDemand(_)) + } + + fn stop_on_demand_search(&mut self) { + self.search_active = false; + self.search_query.clear(); + self.apply_filter(); + } + pub(crate) fn set_search_query(&mut self, query: String) { let selected_actual_idx = self.selected_actual_idx(); + self.search_active = true; self.search_query = query; self.state.selected_idx = None; self.initial_selected_idx = selected_actual_idx; @@ -1097,12 +1146,18 @@ impl BottomPaneView for ListSelectionView { crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) } + fn accepts_input_when_disconnected(&self) -> bool { + self.allow_input_when_disconnected + } + fn handle_key_event(&mut self, key_event: KeyEvent) { - // Searchable lists reserve printable characters for query input. This - // keeps vim-style plain j/k/h/l useful in non-search lists without - // making those letters impossible to type into a filter. + // An active search reserves printable keys for the query. On-demand search leaves the + // configured list bindings untouched until its shortcut is pressed. + let search_field_visible = self.search_field_visible(); let allow_plain_char_navigation = - !self.is_searchable || !is_plain_text_key_event(key_event); + !search_field_visible || !is_plain_text_key_event(key_event); + let allow_plain_char_actions = + !self.on_demand_search_active() || !is_plain_text_key_event(key_event); match key_event { _ if self @@ -1143,10 +1198,20 @@ impl BottomPaneView for ListSelectionView { { self.switch_tab(/*step*/ 1) } + KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + .. + } if self.on_demand_search_active() => self.stop_on_demand_search(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } if self.on_demand_search_active() => self.accept(SelectionActionKind::Primary), KeyEvent { code: KeyCode::Backspace, .. - } if self.is_searchable => { + } if search_field_visible => { self.search_query.pop(); self.apply_filter(); } @@ -1155,7 +1220,9 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } if self.selected_item_has_toggle() - && (!self.is_searchable || self.search_query.is_empty()) => + && (!search_field_visible + || matches!(self.search_mode, SearchMode::Immediate) + && self.search_query.is_empty()) => { self.toggle_selected() } @@ -1163,13 +1230,17 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. - } if self.is_searchable + } if search_field_visible + && matches!(self.search_mode, SearchMode::Immediate) && self.search_query.is_empty() && self.selected_item_has_toggle_placeholder() => {} - _ if self.allow_cancel && self.keymap.cancel.is_pressed(key_event) => { + _ if allow_plain_char_actions + && self.allow_cancel + && self.keymap.cancel.is_pressed(key_event) => + { self.on_ctrl_c(); } - _ if self.keymap.accept.is_pressed(key_event) => { + _ if allow_plain_char_actions && self.keymap.accept.is_pressed(key_event) => { self.accept(SelectionActionKind::Primary) } _ if allow_plain_char_navigation @@ -1181,6 +1252,16 @@ impl BottomPaneView for ListSelectionView { { self.accept(SelectionActionKind::Secondary); } + _ if self.is_searchable + && !self.search_active + && matches!( + self.search_mode, + SearchMode::OnDemand(binding) if binding.is_press(key_event) + ) + && !self.keymap.has_chord_prefix(key_event) => + { + self.search_active = true; + } KeyEvent { code: KeyCode::Char(c), .. @@ -1189,7 +1270,7 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Char(c), modifiers, .. - } if self.is_searchable + } if search_field_visible && !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { @@ -1227,7 +1308,7 @@ impl BottomPaneView for ListSelectionView { } fn handle_paste(&mut self, pasted: String) -> bool { - if !self.is_searchable { + if !self.search_field_visible() { return false; } let Some(pasted) = normalize_pasted_search_query(&pasted) else { @@ -1275,6 +1356,10 @@ impl BottomPaneView for ListSelectionView { } fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.on_demand_search_active() { + self.stop_on_demand_search(); + return CancellationEvent::Handled; + } if !self.allow_cancel { return CancellationEvent::NotHandled; } @@ -1287,6 +1372,14 @@ impl BottomPaneView for ListSelectionView { } impl Renderable for ListSelectionView { + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { + if self.on_demand_search_active() { + self.search_cursor.get() + } else { + None + } + } + fn desired_height(&self, width: u16) -> u16 { // Inner content width after menu surface horizontal insets (2 per side). let inner_width = popup_content_width(width); @@ -1319,15 +1412,16 @@ impl Renderable for ListSelectionView { let rows_height = self.result_area_height(rows_height); let tab_height = tab_bar_height(&self.tabs, inner_width); + let search_field_visible = self.search_field_visible(); let mut height = self.header_height(inner_width); let header_gap = if height == 0 { 0 } else { self.header_gap }; - let tab_gap = u16::from(tab_height > 0 && self.is_searchable); + let tab_gap = u16::from(tab_height > 0 && search_field_visible); height = height.saturating_add(tab_height + tab_gap); height = height.saturating_add(rows_height + 2 + header_gap); if self.picker_surface == PickerSurface::Panel { height = height.saturating_add(/*rhs*/ 1); } - if self.is_searchable { + if search_field_visible { height = height.saturating_add(/*rhs*/ 1); } @@ -1357,9 +1451,11 @@ impl Renderable for ListSelectionView { fn render(&self, area: Rect, buf: &mut Buffer) { self.rendered_item_count.set(/*val*/ 0); + self.search_cursor.set(/*val*/ None); if area.height == 0 || area.width == 0 { return; } + let search_field_visible = self.search_field_visible(); let note_width = area.width.saturating_sub(2); let note_lines = self @@ -1377,7 +1473,7 @@ impl Renderable for ListSelectionView { let minimum_content = u16::from(self.picker_surface == PickerSurface::Panel) + header_height.min(/*other*/ 1) + tab_height - + u16::from(self.is_searchable) + + u16::from(search_field_visible) + 1; footer_rows = footer_rows.min(area.height.saturating_sub(minimum_content)); let [content_area, footer_area] = @@ -1460,13 +1556,13 @@ impl Renderable for ListSelectionView { header: header_height, header_gap: self.header_gap, tabs: tab_height, - search: u16::from(self.is_searchable), + search: u16::from(search_field_visible), rows: rows_height, side: stacked_side_h, }, ); - if !self.is_searchable && header_area.height < header_height { + if !search_field_visible && header_area.height < header_height { let [header_area, elision_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); header.render(header_area, buf); @@ -1492,8 +1588,7 @@ impl Renderable for ListSelectionView { } // -- Search bar -- - if self.is_searchable { - Line::from(self.search_query.clone()).render(search_area, buf); + if search_field_visible { let query_span: Span<'static> = if self.search_query.is_empty() { self.search_placeholder .as_ref() @@ -1502,7 +1597,19 @@ impl Renderable for ListSelectionView { } else { self.search_query.clone().into() }; - Line::from(query_span).render(search_area, buf); + if self.on_demand_search_active() { + let label = "Search › "; + buf.set_style(search_area, crate::bottom_pane::active_tab_style()); + Line::from(vec![label.cyan().bold(), query_span]).render(search_area, buf); + let cursor_offset = (label.width() + self.search_query.width()) + .min(usize::from(search_area.width.saturating_sub(/*rhs*/ 1))); + self.search_cursor.set(Some(( + search_area.x.saturating_add(cursor_offset as u16), + search_area.y, + ))); + } else { + Line::from(query_span).render(search_area, buf); + } } // -- List rows -- @@ -1750,6 +1857,39 @@ mod tests { } } + #[test] + fn searchable_list_honors_configured_printable_accept_alternative() { + let (tx, mut rx) = unbounded_channel(); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.accept = vec![ + crate::key_hint::plain(KeyCode::Char('z')), + crate::key_hint::plain(KeyCode::Enter), + ]; + let mut view = ListSelectionView::new( + SelectionViewParams { + is_searchable: true, + items: vec![SelectionItem { + name: "Selection".into(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::UpdateModel("accepted".into())) + })], + ..Default::default() + }], + ..Default::default() + }, + AppEventSender::new(tx), + keymap, + ); + + view.handle_key_event(KeyEvent::from(KeyCode::Char('z'))); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::UpdateModel(model)) if model == "accepted" + )); + assert_eq!(view.search_query(), Some("")); + } + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f81ddedd77..fa7df2191c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -77,6 +77,7 @@ mod app_link_view; mod apply_patch_header; mod approval_overlay; mod async_questions; +mod config_panel; mod empty_state_policy; mod hook_status; mod mcp_server_elicitation; @@ -172,6 +173,7 @@ pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::ListSelectionView; pub(crate) use list_selection_view::OnSelectionChangedCallback; pub(crate) use list_selection_view::PickerSurface; +pub(crate) use list_selection_view::SearchMode; pub(crate) use list_selection_view::SelectionDescriptionLayout; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; @@ -294,6 +296,7 @@ pub(crate) struct BottomPane { warnings_view: Option, /// A keep press can close the viewer; its remaining repeats must not edit the draft. pub(crate) suppress_warning_keep_repeat: bool, + view_transition_key_guard: Option, pub(crate) questions: Option>, delayed_approval_requests: VecDeque, last_composer_activity_at: Option, @@ -382,6 +385,7 @@ impl BottomPane { key_chord_reset_requested: false, warnings_view: None, suppress_warning_keep_repeat: false, + view_transition_key_guard: None, questions: None, delayed_approval_requests: VecDeque::new(), last_composer_activity_at: None, @@ -820,6 +824,32 @@ impl BottomPane { /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { + self.handle_key_event_with_activation_key(key_event, /*activation_key*/ None) + } + + pub(crate) fn handle_key_event_with_activation_key( + &mut self, + key_event: KeyEvent, + activation_key: Option, + ) -> InputResult { + let routed_key = activation_key.unwrap_or_else(|| KeyBinding::from_event(key_event)); + if self.consume_view_transition_key(routed_key, key_event.kind) { + return InputResult::None; + } + if key_event.kind == KeyEventKind::Release { + // A modal may cover the config panel while its child editor is queued. + // Let the panel see the release so the next fresh press is not swallowed. + for view in &mut self.view_stack { + view.handle_key_release(key_event); + } + } else if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && self.view_stack.len() > 1 + { + let covered_view_count = self.view_stack.len() - 1; + for view in &mut self.view_stack[..covered_view_count] { + view.observe_key_event_while_covered(key_event, routed_key); + } + } let records_composer_activity = matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && !key_hint::has_ctrl_or_alt(key_event.modifiers) @@ -884,7 +914,7 @@ impl BottomPane { if ctrl_c_completed { (true, true, view.completion(), false) } else { - view.handle_key_event(key_event); + view.handle_key_event_with_activation_key(key_event, activation_key); ( false, view.is_complete(), @@ -1003,6 +1033,7 @@ impl BottomPane { } pub fn handle_paste(&mut self, pasted: String) { + self.interrupt_config_editor_transition(); if self.warnings_active() { if !pasted.is_empty() { self.record_composer_activity_at(Instant::now()); @@ -1770,6 +1801,11 @@ impl BottomPane { !self.view_stack.is_empty() } + pub(crate) fn active_view_accepts_input_when_disconnected(&self) -> bool { + self.active_view() + .is_some_and(BottomPaneView::accepts_input_when_disconnected) + } + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { self.is_task_running && self @@ -1777,7 +1813,6 @@ impl BottomPane { .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) } - #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) } @@ -1845,6 +1880,9 @@ impl BottomPane { } pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { + if event.kind != crossterm::event::MouseEventKind::Moved { + self.view_transition_key_guard = None; + } if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) { self.composer.end_mouse_drag(); return false; @@ -1860,6 +1898,34 @@ impl BottomPane { self.push_view(view); } + pub(crate) fn suppress_current_view_key_until_release( + &mut self, + key: KeyBinding, + activated_at: Instant, + ) { + self.view_transition_key_guard = + Some(crate::activation_key_guard::ActivationKeyGuard::new_at( + key, + activated_at, + self.enhanced_keys_supported, + )); + } + + pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool { + self.consume_view_transition_key(KeyBinding::from_event(key_event), key_event.kind) + } + + fn consume_view_transition_key(&mut self, key: KeyBinding, kind: KeyEventKind) -> bool { + let Some(guard) = self.view_transition_key_guard.as_mut() else { + return false; + }; + let consumed = guard.consume(key, kind); + if !guard.is_active() { + self.view_transition_key_guard = None; + } + consumed + } + /// Show a text prompt with the composer's current editing preferences. pub(crate) fn show_text_prompt(&mut self, mut view: custom_prompt_view::CustomPromptView) { view.set_keymap_bindings(&self.keymap); @@ -2771,6 +2837,75 @@ mod tests { } } + #[test] + fn view_transition_key_guard_survives_an_intervening_modal() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut pane = test_pane(AppEventSender::new(tx_raw)); + let activation_key = crate::key_hint::plain(KeyCode::Enter); + pane.suppress_current_view_key_until_release(activation_key, Instant::now()); + pane.push_view(Box::new(DismissibleView::default())); + + assert!(pane.consume_view_transition_key(activation_key, KeyEventKind::Repeat)); + assert!(pane.consume_view_transition_key(activation_key, KeyEventKind::Release)); + assert!(!pane.consume_view_transition_key(activation_key, KeyEventKind::Press)); + } + + #[test] + fn transition_events_reach_a_view_covered_by_a_modal() { + #[derive(Default)] + struct CoveredEvents { + press_seen: Cell, + release_seen: Cell, + interrupted: Cell, + } + + struct TrackingView(Rc); + + impl Renderable for TrackingView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for TrackingView { + fn observe_key_event_while_covered( + &mut self, + _key_event: KeyEvent, + _activation_key: KeyBinding, + ) { + self.0.press_seen.set(true); + } + + fn handle_key_release(&mut self, _key_event: KeyEvent) { + self.0.release_seen.set(true); + } + + fn interrupt_config_editor_transition(&mut self) { + self.0.interrupted.set(true); + } + } + + let (tx_raw, _rx) = unbounded_channel::(); + let mut pane = test_pane(AppEventSender::new(tx_raw)); + let events = Rc::new(CoveredEvents::default()); + pane.push_view(Box::new(TrackingView(events.clone()))); + pane.push_view(Box::new(DismissibleView::default())); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + pane.handle_paste("pasted".to_string()); + + assert!(events.press_seen.get()); + assert!(events.release_seen.get()); + assert!(events.interrupted.get()); + } + #[derive(Default)] struct CompletingView { id: Option<&'static str>, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 3d20f5cb93..9567424fbb 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -405,6 +405,10 @@ impl BottomPaneView for StatusLineSetupView { crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) } + fn accepts_input_when_disconnected(&self) -> bool { + true + } + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { self.picker.handle_key_event(key_event); } diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index ebfc53ca97..145938baf4 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -371,6 +371,10 @@ impl BottomPaneView for TerminalTitleSetupView { crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) } + fn accepts_input_when_disconnected(&self) -> bool { + true + } + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { self.picker.handle_key_event(key_event); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2c02a6d344..2f74f843d5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -257,6 +257,7 @@ use crate::tui::FrameRequester; mod activity_groups; mod activity_presentation; mod command_lifecycle; +mod config_panel; mod connector_mentions; mod connectors; mod constructor; @@ -297,6 +298,7 @@ mod misalignment_policy; pub(crate) use misalignment_policy::MisalignmentReview; pub(crate) use misalignment_policy::MisalignmentTurnSource; mod pets; +pub(crate) use self::pets::ConfiguredPetLoadGeneration; mod session_flow; mod session_header; use self::session_header::SessionHeader; @@ -523,6 +525,7 @@ pub(crate) struct ChatWidget { transcript: TranscriptState, config: Config, pub(crate) local_settings: crate::local_settings::LocalSettings, + pub(crate) config_panel_tui: codex_config::types::Tui, raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, @@ -658,6 +661,7 @@ pub(crate) struct ChatWidget { pub(crate) pet_http_client: codex_http_client::RouteAwareClientPool, // Ambient companion rendered over the transcript area, never inside the footer rows. ambient_pet: Option, + configured_pet_load_generation: ConfiguredPetLoadGeneration, pet_picker_preview_state: crate::pets::PetPickerPreviewState, pet_picker_preview_pet: Option, pet_picker_preview_request_id: u64, @@ -697,7 +701,7 @@ pub(crate) struct ChatWidget { chat_keymap: ChatKeymap, permission_shortcut_pending: bool, // Pending notification to show when unfocused on next Draw - pending_notification: Option, + pub(crate) pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press /// must occur before `quit_shortcut_expires_at`. quit_shortcut_expires_at: Option, diff --git a/codex-rs/tui/src/chatwidget/config_panel.rs b/codex-rs/tui/src/chatwidget/config_panel.rs new file mode 100644 index 0000000000..c96b8a1269 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/config_panel.rs @@ -0,0 +1,112 @@ +//! Entry points for the configuration browser and its existing child editors. + +use super::ChatWidget; +use crate::config_panel::ConfigEditor; +use crate::config_panel::ConfigEditorHandoff; +use crate::config_panel::ConfigPanel; +use crate::config_panel::ConfigPanelStatus; +use crate::config_panel::ConfigPanelUpdate; +use crossterm::event::KeyEvent; + +impl ChatWidget { + pub(crate) fn open_config_panel(&mut self) { + self.bottom_pane.show_view(Box::new(ConfigPanel::new( + &self.config_panel_tui, + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + self.config_panel_status(), + ))); + } + + pub(crate) fn open_config_editor( + &mut self, + handoff: ConfigEditorHandoff, + resume_current_availability: crate::config_panel::ResumeCurrentAvailability, + ) { + match handoff.editor { + ConfigEditor::Theme => self.open_theme_picker(), + ConfigEditor::StatusLine => self.open_status_line_setup(), + ConfigEditor::TerminalTitle => self.open_terminal_title_setup(), + ConfigEditor::Keyboard => self.open_keymap_picker(), + ConfigEditor::SessionPickerLayout => { + let keymap = self.bottom_pane.list_keymap(); + let params = + crate::config_panel::session_picker_layout(&self.local_settings.tui, &keymap); + self.bottom_pane.show_selection_view(params); + } + ConfigEditor::ResumeDirectory => { + let keymap = self.bottom_pane.list_keymap(); + let params = crate::config_panel::resume_directory( + &self.local_settings.tui, + &keymap, + resume_current_availability, + ); + self.bottom_pane.show_selection_view(params); + } + ConfigEditor::CopyOnSelect => { + let keymap = self.bottom_pane.list_keymap(); + let params = crate::config_panel::copy_on_select(&self.local_settings.tui, &keymap); + self.bottom_pane.show_selection_view(params); + } + ConfigEditor::RightClickPaste => { + let keymap = self.bottom_pane.list_keymap(); + let params = + crate::config_panel::right_click_paste(&self.local_settings.tui, &keymap); + self.bottom_pane.show_selection_view(params); + } + ConfigEditor::NotificationCondition => { + let keymap = self.bottom_pane.list_keymap(); + let params = + crate::config_panel::notification_condition(&self.local_settings.tui, &keymap); + self.bottom_pane.show_selection_view(params); + } + } + if let Some(activated_at) = handoff.active_at() { + self.bottom_pane + .suppress_current_view_key_until_release(handoff.activation_key, activated_at); + } + self.bottom_pane.config_editor_opened(); + } + + pub(crate) fn interrupt_config_editor_transition(&mut self) { + self.bottom_pane.interrupt_config_editor_transition(); + } + + pub(crate) fn observe_config_editor_transition_key_event(&mut self, key: KeyEvent) { + self.bottom_pane + .observe_config_editor_transition_key_event(key); + } + + pub(crate) fn refresh_config_panel(&mut self, notice: Option) -> bool { + self.bottom_pane.update_config_panel(&ConfigPanelUpdate { + tui: self.config_panel_tui.clone(), + keymap: self.bottom_pane.list_keymap(), + status: self.config_panel_status(), + notice, + }) + } + + fn config_panel_status(&self) -> ConfigPanelStatus { + ConfigPanelStatus { + copy_on_select: self + .local_settings + .copy_on_select(&codex_terminal_detection::terminal_info()), + right_click_paste: crate::app::PasteEnvironment::detect().status( + self.local_settings.tui.right_click_paste, + self.local_settings.transcript_mode, + ), + custom_notification_filters: self.local_settings.custom_notification_filters, + } + } + + pub(crate) fn report_config_save_error(&mut self, message: String) { + self.add_error_message(message.clone()); + self.refresh_config_panel(Some(message)); + } + + pub(crate) fn config_editor_open_failed(&mut self, message: String) { + self.bottom_pane.config_editor_opened(); + self.add_error_message(message.clone()); + self.refresh_config_panel(Some(message)); + } +} diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 9af84e80a7..05dde949ed 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -88,13 +88,14 @@ impl ChatWidget { config.http_client_factory(), codex_http_client::ClientRouteClass::Other, ); - pets::start_configured_pet_load_if_needed( + let configured_pet_load_generation = pets::start_configured_pet_load_if_needed( &local_settings, /*ambient_pet_missing*/ true, frame_requester.clone(), app_event_tx.clone(), pet_http_client.clone(), ); + let config_panel_tui = local_settings.tui.clone(); let mut widget = Self { empty_state_animation: std::cell::RefCell::new(empty_state_animation), daybreak_enabled: false, @@ -116,6 +117,7 @@ impl ChatWidget { raw_output_mode: local_settings.tui.raw_output_mode, config, local_settings, + config_panel_tui, effective_service_tier, skills_all: Vec::new(), skills_initial_state: None, @@ -220,6 +222,7 @@ impl ChatWidget { active_hook_cell: None, pet_http_client, ambient_pet: None, + configured_pet_load_generation, pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(), pet_picker_preview_pet: None, pet_picker_preview_request_id: 0, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 6ee018ed4c..5b44cac1b0 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -30,6 +30,7 @@ impl ChatWidget { SlashCommand::Model | SlashCommand::Keymap | SlashCommand::Memories + | SlashCommand::Config | SlashCommand::Title | SlashCommand::Statusline | SlashCommand::Theme, diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 2d125f88b5..0825bee657 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -65,7 +65,21 @@ impl ChatWidget { std::mem::take(&mut self.bottom_pane.key_chord_reset_requested) } + pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool { + self.bottom_pane + .consume_view_transition_key_event(key_event) + } + + #[cfg(test)] pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventAction { + self.handle_key_event_with_activation_key(key_event, /*activation_key*/ None) + } + + pub(crate) fn handle_key_event_with_activation_key( + &mut self, + key_event: KeyEvent, + activation_key: Option, + ) -> KeyEventAction { if self.handle_startup_submission_key(key_event) { return KeyEventAction::None; } @@ -89,7 +103,8 @@ impl ChatWidget { let should_pause_active_goal = self .bottom_pane .active_view_will_interrupt_turn_on_key_event(key_event); - self.bottom_pane.handle_key_event(key_event); + self.bottom_pane + .handle_key_event_with_activation_key(key_event, activation_key); if should_pause_active_goal { self.pause_active_goal_for_interrupt(); } @@ -100,7 +115,8 @@ impl ChatWidget { } if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) { - self.bottom_pane.handle_key_event(key_event); + self.bottom_pane + .handle_key_event_with_activation_key(key_event, activation_key); return KeyEventAction::None; } @@ -258,7 +274,9 @@ impl ChatWidget { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); let should_pause_active_goal = self.bottom_pane.should_interrupt_running_task(key_event); - let input_result = self.bottom_pane.handle_key_event(key_event); + let input_result = self + .bottom_pane + .handle_key_event_with_activation_key(key_event, activation_key); if matches!( input_result, InputResult::None | InputResult::ParentOwnedInputBlocked diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 1db2956369..cd4ffdb241 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -172,6 +172,7 @@ impl ChatWidget { self.copy_last_response_binding = runtime_keymap.app.copy.clone(); self.chat_keymap = runtime_keymap.chat.clone(); self.bottom_pane.set_keymap_bindings(runtime_keymap); + self.refresh_config_panel(Some("Saved".into())); self.update_realtime_footer(); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/notifications.rs b/codex-rs/tui/src/chatwidget/notifications.rs index 2668e962a4..92ef60bada 100644 --- a/codex-rs/tui/src/chatwidget/notifications.rs +++ b/codex-rs/tui/src/chatwidget/notifications.rs @@ -24,7 +24,7 @@ impl ChatWidget { } #[derive(Debug)] -pub(super) enum Notification { +pub(crate) enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs index dc9bfa6121..90be6317da 100644 --- a/codex-rs/tui/src/chatwidget/pets.rs +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -2,6 +2,22 @@ use super::*; use codex_config::types::TuiPetAnchor; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +// A configured-pet load can finish after its ChatWidget is replaced, so +// generations must remain unique across widget replacements. +static NEXT_CONFIGURED_PET_LOAD_GENERATION: AtomicU64 = AtomicU64::new(/*v*/ 1); + +/// Identifies one configured-pet load across widget replacements. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ConfiguredPetLoadGeneration(u64); + +fn next_configured_pet_load_generation() -> ConfiguredPetLoadGeneration { + ConfiguredPetLoadGeneration( + NEXT_CONFIGURED_PET_LOAD_GENERATION.fetch_add(/*val*/ 1, Ordering::Relaxed), + ) +} pub(super) fn load_ambient_pet( config: &crate::local_settings::LocalSettings, @@ -27,12 +43,13 @@ pub(super) fn start_configured_pet_load_if_needed( frame_requester: FrameRequester, app_event_tx: AppEventSender, pet_http_client: codex_http_client::RouteAwareClientPool, -) { +) -> ConfiguredPetLoadGeneration { + let generation = next_configured_pet_load_generation(); let Some(pet_id) = config.tui.pet.clone() else { - return; + return generation; }; if pet_id == crate::pets::DISABLED_PET_ID || !ambient_pet_missing { - return; + return generation; } let codex_home = config.codex_home.clone(); @@ -53,10 +70,12 @@ pub(super) fn start_configured_pet_load_if_needed( }, app_event_tx, move |result| AppEvent::ConfiguredPetLoaded { + generation, pet_id: event_pet_id, result, }, ); + generation } impl ChatWidget { @@ -210,15 +229,48 @@ impl ChatWidget { pub(crate) fn set_tui_pet(&mut self, pet: Option) { self.local_settings.tui.pet = pet; self.ambient_pet = load_ambient_pet(&self.local_settings, self.frame_requester.clone()); + self.configured_pet_load_generation = start_configured_pet_load_if_needed( + &self.local_settings, + /*ambient_pet_missing*/ self.ambient_pet.is_none(), + self.frame_requester.clone(), + self.app_event_tx.clone(), + self.pet_http_client.clone(), + ); self.apply_ambient_pet_image_support_override_for_tests(); self.request_redraw(); } + /// Applies a configured-pet result only if it belongs to this widget's current load. + pub(crate) fn finish_configured_pet_load( + &mut self, + generation: ConfiguredPetLoadGeneration, + pet_id: String, + result: Result, String>, + ) -> bool { + if generation != self.configured_pet_load_generation + || self.local_settings.tui.pet.as_deref() != Some(pet_id.as_str()) + { + return false; + } + + match result { + Ok(ambient_pet) => { + self.set_tui_pet_loaded(Some(pet_id), ambient_pet); + true + } + Err(err) => { + self.add_warning_message(format!("Failed to load configured pet: {err}")); + false + } + } + } + pub(crate) fn set_tui_pet_loaded( &mut self, pet: Option, ambient_pet: Option, ) { + self.configured_pet_load_generation = next_configured_pet_load_generation(); self.local_settings.tui.pet = pet; self.ambient_pet = ambient_pet; self.apply_ambient_pet_image_support_override_for_tests(); diff --git a/codex-rs/tui/src/chatwidget/pets_tests.rs b/codex-rs/tui/src/chatwidget/pets_tests.rs index 796339acfc..61ac065692 100644 --- a/codex-rs/tui/src/chatwidget/pets_tests.rs +++ b/codex-rs/tui/src/chatwidget/pets_tests.rs @@ -11,13 +11,14 @@ fn pet_load_without_runtime_sends_completion_event() { async { Ok::, String>(None) }, app_event_tx, |result| AppEvent::ConfiguredPetLoaded { + generation: next_configured_pet_load_generation(), pet_id: crate::pets::DEFAULT_PET_ID.to_string(), result, }, ); match rx.blocking_recv().expect("pet load completion event") { - AppEvent::ConfiguredPetLoaded { pet_id, result } => { + AppEvent::ConfiguredPetLoaded { pet_id, result, .. } => { assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID); assert!(result.expect("successful pet load").is_none()); } @@ -25,6 +26,70 @@ fn pet_load_without_runtime_sends_completion_event() { } } +#[tokio::test] +async fn configured_pet_loads_are_scoped_across_widget_replacements() { + let (mut replaced_chat, _replaced_tx, mut replaced_rx, _replaced_op_rx) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + let (mut chat, _tx, mut rx, _op_rx) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + let codex_home = tempfile::tempdir().unwrap(); + replaced_chat.local_settings.codex_home = + AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("absolute temporary path"); + chat.local_settings.codex_home = + AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("absolute temporary path"); + let pet_id = "missing-custom-pet".to_string(); + + replaced_chat.set_tui_pet(/*pet*/ Some(pet_id.clone())); + let replaced_event = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + replaced_rx.recv(), + ) + .await + .expect("replaced widget pet load completion") + .expect("replaced widget pet load event"); + let AppEvent::ConfiguredPetLoaded { + generation: replaced_generation, + .. + } = replaced_event + else { + panic!("expected configured pet completion"); + }; + + chat.set_tui_pet(/*pet*/ Some(pet_id.clone())); + let event = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), rx.recv()) + .await + .expect("configured pet load completion") + .expect("configured pet load event"); + let AppEvent::ConfiguredPetLoaded { + generation, + pet_id: loaded_pet_id, + .. + } = event + else { + panic!("expected configured pet completion"); + }; + assert_eq!(loaded_pet_id, pet_id); + + assert!(!chat.finish_configured_pet_load( + replaced_generation, + pet_id.clone(), + /*result*/ Ok(None), + )); + assert!(chat.finish_configured_pet_load( + generation, + pet_id, + /*result*/ + Ok(Some(crate::pets::test_ambient_pet( + chat.frame_requester.clone(), + /*animations_enabled*/ false, + ))), + )); + let area = ratatui::layout::Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ); + assert!(chat.ambient_pet_draw(area, area.bottom()).is_some()); +} + #[tokio::test] async fn shared_pet_load_uses_cached_builtin_assets() { let (chat, _tx, _rx, _op_rx) = diff --git a/codex-rs/tui/src/chatwidget/reconnect.rs b/codex-rs/tui/src/chatwidget/reconnect.rs index f181fd541f..d50d9d5a43 100644 --- a/codex-rs/tui/src/chatwidget/reconnect.rs +++ b/codex-rs/tui/src/chatwidget/reconnect.rs @@ -161,8 +161,35 @@ impl ChatWidget { self.refresh_pending_input_preview(); } - pub(crate) fn handle_disconnected_view_key(&mut self, key: KeyEvent) { - self.bottom_pane.handle_key_event(key); + pub(crate) fn handle_disconnected_view_key( + &mut self, + key: KeyEvent, + activation_key: Option, + ) { + if crate::key_hint::ctrl(KeyCode::Char('c')).is_press(key) { + let _ = self.cancel_active_disconnected_view(); + } else { + self.bottom_pane + .handle_key_event_with_activation_key(key, activation_key); + } + } + + pub(crate) fn active_view_accepts_input_when_disconnected(&self) -> bool { + self.bottom_pane + .active_view_accepts_input_when_disconnected() + } + + pub(crate) fn cancel_active_disconnected_view(&mut self) -> bool { + let had_view = self.active_view_accepts_input_when_disconnected(); + if had_view { + self.revert_terminal_title_setup_preview(); + let _ = self.bottom_pane.on_ctrl_c(); + } + had_view + } + + pub(crate) fn active_view_is(&self, view_id: &'static str) -> bool { + self.bottom_pane.active_view_id() == Some(view_id) } pub(crate) fn handle_restricted_key(&mut self, key: KeyEvent, mode: RestrictedInputMode) { diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 2f4e80152b..fa303a6748 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -304,6 +304,7 @@ impl ChatWidget { /// Set the syntax theme override in the widget's config copy. pub(crate) fn set_tui_theme(&mut self, theme: Option) { + self.config_panel_tui.theme.clone_from(&theme); self.local_settings.tui.theme = theme; } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 182a696b24..5327f805cf 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -585,6 +585,9 @@ impl ChatWidget { SlashCommand::Statusline => { self.open_status_line_setup(); } + SlashCommand::Config => { + self.open_config_panel(); + } SlashCommand::Theme => { self.open_theme_picker(); } @@ -1333,6 +1336,7 @@ impl ChatWidget { | SlashCommand::Hooks | SlashCommand::Title | SlashCommand::Statusline + | SlashCommand::Config | SlashCommand::Theme | SlashCommand::Tui | SlashCommand::Pets => QueueDrain::Stop, diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 6dc0ab9077..692eba105a 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -129,6 +129,8 @@ impl ChatWidget { "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" ); let ids = items.iter().map(ToString::to_string).collect::>(); + self.config_panel_tui.status_line = Some(ids.clone()); + self.config_panel_tui.status_line_use_colors = use_theme_colors; self.local_settings.tui.status_line = Some(ids); self.local_settings.tui.status_line_use_colors = use_theme_colors; self.refresh_status_line(); @@ -171,6 +173,7 @@ impl ChatWidget { tracing::info!("terminal title setup confirmed with items: {items:#?}"); let ids = items.iter().map(ToString::to_string).collect::>(); self.terminal_title_setup_original_items = None; + self.config_panel_tui.terminal_title = Some(ids.clone()); self.local_settings.tui.terminal_title = Some(ids); self.refresh_terminal_title(); } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 307e1f5128..301b55c2cc 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -612,7 +612,7 @@ async fn configured_pet_load_is_deferred_until_after_construction() { .unwrap(); assert_matches!( event, - AppEvent::ConfiguredPetLoaded { pet_id, result } => { + AppEvent::ConfiguredPetLoaded { pet_id, result, .. } => { assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID); assert!(result.unwrap().is_some()); } diff --git a/codex-rs/tui/src/config_panel/config_panel_tests.rs b/codex-rs/tui/src/config_panel/config_panel_tests.rs new file mode 100644 index 0000000000..51c53b9424 --- /dev/null +++ b/codex-rs/tui/src/config_panel/config_panel_tests.rs @@ -0,0 +1,505 @@ +//! Snapshot each configuration group and exercise shared panel interactions. + +use super::*; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; +use codex_config::types::CopyOnSelect; +use codex_config::types::Notifications; +use codex_config::types::ResumeCwdMode; +use codex_config::types::RightClickPaste; +use codex_config::types::SessionPickerViewMode; +use codex_config::types::Tui; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; + +const STATUS: ConfigPanelStatus = ConfigPanelStatus { + copy_on_select: true, + right_click_paste: Ok(false), + custom_notification_filters: false, +}; + +fn panel() -> (ConfigPanel, UnboundedReceiver) { + let (tx, rx) = unbounded_channel(); + ( + ConfigPanel::new( + &Tui::default(), + AppEventSender::new(tx), + RuntimeKeymap::defaults().list, + STATUS, + ), + rx, + ) +} + +fn render(view: &impl Renderable, width: u16) -> String { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + view.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +#[test] +fn config_panel_renders_each_group_at_a_stable_height() { + let (mut panel, _rx) = panel(); + let height = panel.desired_height(/*width*/ 140); + for section in ConfigSection::ALL { + assert_eq!(panel.picker.active_tab_id(), Some(section.title())); + assert_eq!(panel.desired_height(/*width*/ 140), height); + insta::assert_snapshot!( + format!("config_{}", section.title()), + render(&panel, /*width*/ 140) + ); + panel + .picker + .handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + } +} + +#[test] +fn deselect_after_copy_requires_effective_copy_on_select() { + let (mut panel, mut rx) = self::panel(); + panel.handle_key_event(KeyCode::Left.into()); + panel.handle_key_event(KeyCode::Char('/').into()); + assert!(panel.handle_paste("deselect after copy".to_string())); + panel.update_config_panel(&ConfigPanelUpdate { + tui: Tui::default(), + keymap: RuntimeKeymap::defaults().list, + status: ConfigPanelStatus { + copy_on_select: false, + ..STATUS + }, + notice: None, + }); + insta::assert_snapshot!( + "deselect_after_copy_unavailable", + render(&panel, /*width*/ 140) + ); + panel.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); + + let (mut panel, mut rx) = self::panel(); + panel.handle_key_event(KeyCode::Left.into()); + panel.handle_key_event(KeyCode::Char('/').into()); + assert!(panel.handle_paste("deselect after copy".to_string())); + panel.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("save toggle event"), + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::CopyOnSelectClearSelection, + enabled: true, + }) + )); +} + +#[test] +fn notification_controls_render_dependency_states_and_condition_picker() { + let panel_with_notifications = |notifications, custom_notification_filters| { + let (tx, _rx) = unbounded_channel(); + let mut tui = Tui::default(); + tui.notification_settings.notifications = notifications; + ConfigPanel::new( + &tui, + AppEventSender::new(tx), + RuntimeKeymap::defaults().list, + ConfigPanelStatus { + custom_notification_filters, + ..STATUS + }, + ) + }; + let mut panel = panel_with_notifications( + Notifications::Enabled(true), + /*custom_notification_filters*/ true, + ); + panel.handle_key_event(KeyCode::Right.into()); + insta::assert_snapshot!("config_notifications_custom", render(&panel, /*width*/ 100)); + + let (tx, _rx) = unbounded_channel(); + let keymap = RuntimeKeymap::defaults().list; + let picker = ListSelectionView::new( + notification_condition(&Tui::default(), &keymap), + AppEventSender::new(tx), + keymap, + ); + insta::assert_snapshot!( + "config_notification_condition_choice", + render(&picker, /*width*/ 100) + ); + + let mut panel = panel_with_notifications( + Notifications::Enabled(false), + /*custom_notification_filters*/ false, + ); + panel.handle_key_event(KeyCode::Right.into()); + insta::assert_snapshot!("config_notifications_off", render(&panel, /*width*/ 100)); +} + +#[test] +fn filtered_selection_opens_the_matching_existing_editor() { + let (mut panel, mut rx) = panel(); + panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + assert!(panel.handle_paste("terminal title".to_string())); + panel.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + rx.try_recv().expect("open editor event"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::TerminalTitle + )); + assert!(rx.try_recv().is_err()); + assert!(!panel.is_complete()); +} + +#[test] +fn on_demand_search_preserves_printable_list_bindings() { + let (tx, mut rx) = unbounded_channel(); + let mut keymap = RuntimeKeymap::defaults().list; + keymap.accept = vec![crate::key_hint::plain(KeyCode::Char('t'))]; + keymap.cancel = vec![crate::key_hint::plain(KeyCode::Char('h'))]; + let mut panel = ConfigPanel::new( + &Tui::default(), + AppEventSender::new(tx), + keymap.clone(), + STATUS, + ); + + panel.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); + assert!(matches!( + rx.try_recv().expect("configured accept binding"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::Theme + )); + + let (tx, mut rx) = unbounded_channel(); + let mut panel = ConfigPanel::new(&Tui::default(), AppEventSender::new(tx), keymap, STATUS); + panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + + for character in "theme".chars() { + panel.handle_key_event(KeyEvent::from(KeyCode::Char(character))); + } + assert_eq!(panel.picker.search_query(), Some("theme")); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 84, + panel.desired_height(/*width*/ 84), + ); + let mut buffer = Buffer::empty(area); + panel.render(area, &mut buffer); + assert!(panel.cursor_pos(area).is_some()); + panel.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(matches!( + rx.try_recv().expect("open theme editor event"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::Theme + )); +} + +#[test] +fn search_matches_displayed_values() { + let (tx, mut rx) = unbounded_channel(); + let mut panel = ConfigPanel::new( + &Tui { + theme: Some("Dracula".to_string()), + ..Default::default() + }, + AppEventSender::new(tx), + RuntimeKeymap::defaults().list, + STATUS, + ); + panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + assert!(panel.handle_paste("Dracula".to_string())); + panel.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(matches!( + rx.try_recv().expect("open displayed theme editor event"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::Theme + )); +} + +#[test] +fn session_picker_layout_uses_editor_handoff_and_saves_choice() { + let (mut panel, mut rx) = panel(); + for _ in 0..3 { + panel.handle_key_event(KeyCode::Right.into()); + } + panel.handle_key_event(KeyCode::Char('/').into()); + assert!(panel.handle_paste("session picker layout".to_string())); + panel.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("open editor event"), + AppEvent::OpenConfigEditor(handoff) + if handoff.editor == ConfigEditor::SessionPickerLayout + )); + + let (tx, mut rx) = unbounded_channel(); + let keymap = RuntimeKeymap::defaults().list; + let mut picker = ListSelectionView::new( + session_picker_layout(&Tui::default(), &keymap), + AppEventSender::new(tx), + keymap, + ); + insta::assert_snapshot!( + "config_session_picker_layout_choice", + render(&picker, /*width*/ 100) + ); + picker.handle_key_event(KeyCode::Down.into()); + picker.handle_key_event(KeyCode::Enter.into()); + + assert!(matches!( + rx.try_recv().expect("save choice event"), + AppEvent::SaveConfigPreference(ConfigPreference::SessionPickerLayout( + SessionPickerViewMode::Comfortable, + )) + )); +} + +#[test] +fn copy_on_select_uses_editor_handoff_and_saves_choice() { + let (mut panel, mut rx) = panel(); + for _ in 0..4 { + panel.handle_key_event(KeyCode::Right.into()); + } + panel.handle_key_event(KeyCode::Char('/').into()); + assert!(panel.handle_paste("copy on select".to_string())); + panel.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("open editor event"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::CopyOnSelect + )); + + let (tx, mut rx) = unbounded_channel(); + let keymap = RuntimeKeymap::defaults().list; + let mut picker = ListSelectionView::new( + copy_on_select(&Tui::default(), &keymap), + AppEventSender::new(tx), + keymap, + ); + insta::assert_snapshot!( + "config_copy_on_select_choice", + render(&picker, /*width*/ 100) + ); + picker.handle_key_event(KeyCode::Down.into()); + picker.handle_key_event(KeyCode::Enter.into()); + + assert!(matches!( + rx.try_recv().expect("save choice event"), + AppEvent::SaveConfigPreference(ConfigPreference::CopyOnSelect(CopyOnSelect::Always)) + )); +} + +#[test] +fn unavailable_right_click_paste_remains_editable() { + let (tx, mut rx) = unbounded_channel(); + let mut panel = ConfigPanel::new( + &Tui { + right_click_paste: RightClickPaste::On, + ..Tui::default() + }, + AppEventSender::new(tx), + RuntimeKeymap::defaults().list, + ConfigPanelStatus { + right_click_paste: Err( + "Unavailable in inline mode. Use your terminal's paste command.", + ), + ..STATUS + }, + ); + for _ in 0..4 { + panel.handle_key_event(KeyCode::Right.into()); + } + panel.handle_key_event(KeyCode::Down.into()); + panel.handle_key_event(KeyCode::Down.into()); + insta::assert_snapshot!( + "config_right_click_paste_unavailable", + render(&panel, /*width*/ 140) + ); + panel.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("open editor event"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::RightClickPaste + )); + + let (tx, mut rx) = unbounded_channel(); + let keymap = RuntimeKeymap::defaults().list; + let mut picker = ListSelectionView::new( + right_click_paste(&Tui::default(), &keymap), + AppEventSender::new(tx), + keymap, + ); + insta::assert_snapshot!( + "config_right_click_paste_choice", + render(&picker, /*width*/ 100) + ); + picker.handle_key_event(KeyCode::Down.into()); + picker.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("save choice event"), + AppEvent::SaveConfigPreference(ConfigPreference::RightClickPaste(RightClickPaste::On)) + )); +} + +#[test] +fn resume_directory_disables_current_without_remote_cwd_override() { + let (tx, mut rx) = unbounded_channel(); + let keymap = RuntimeKeymap::defaults().list; + let mut picker = ListSelectionView::new( + resume_directory( + &Tui::default(), + &keymap, + ResumeCurrentAvailability::RequiresCwdOverride, + ), + AppEventSender::new(tx), + keymap, + ); + insta::assert_snapshot!( + "config_resume_directory_remote", + render(&picker, /*width*/ 100) + ); + picker.handle_key_event(KeyCode::Down.into()); + picker.handle_key_event(KeyCode::Enter.into()); + + assert!(matches!( + rx.try_recv().expect("save resume directory event"), + AppEvent::SaveConfigPreference(ConfigPreference::ResumeDirectory(Some( + ResumeCwdMode::Session, + ))) + )); +} + +#[test] +fn refresh_preserves_search_and_updates_visible_state() { + let (mut panel, mut rx) = panel(); + panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + assert!(panel.handle_paste("animations".to_string())); + panel.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv().expect("save toggle event"), + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::Animations, + enabled: true, + }) + )); + assert!(panel.saving); + let mut keymap = RuntimeKeymap::defaults().list; + keymap.move_left.clear(); + keymap.move_right.clear(); + panel.update_config_panel(&ConfigPanelUpdate { + tui: Tui { + animations: true, + ..Default::default() + }, + keymap, + status: STATUS, + notice: Some("Saved · restart Codex to apply".to_string()), + }); + + assert_eq!(panel.picker.search_query(), Some("animations")); + assert!(!panel.saving); + panel.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv().expect("immediate second press saves again"), + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::Animations, + enabled: false, + }) + )); + insta::assert_snapshot!(render(&panel, /*width*/ 140)); +} + +#[test] +fn config_panel_wraps_complete_save_error() { + let (mut panel, _rx) = panel(); + panel.update_config_panel(&ConfigPanelUpdate { + tui: Tui::default(), + keymap: RuntimeKeymap::defaults().list, + status: STATUS, + notice: Some( + "Failed to save status line settings: permission denied while writing config.toml" + .to_string(), + ), + }); + + insta::assert_snapshot!(render(&panel, /*width*/ 50)); +} + +#[test] +fn editor_handoff_blocks_input_until_acknowledged() { + let (tx, mut rx) = unbounded_channel(); + let mut keymap = RuntimeKeymap::defaults().list; + keymap.accept = vec![crate::key_hint::plain(KeyCode::BackTab)]; + let mut panel = ConfigPanel::new(&Tui::default(), AppEventSender::new(tx), keymap, STATUS); + panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + assert!(panel.handle_paste("terminal title".to_string())); + panel.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + let AppEvent::OpenConfigEditor(handoff) = rx.try_recv().expect("open editor event") else { + panic!("expected config editor handoff"); + }; + assert_eq!( + (handoff.editor, handoff.activation_key), + ( + ConfigEditor::TerminalTitle, + crate::key_hint::plain(KeyCode::BackTab) + ) + ); + let activated_at = handoff.active_at().expect("active handoff"); + + panel.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Tab, + KeyModifiers::NONE, + KeyEventKind::Repeat, + )); + assert!( + handoff + .active_at() + .is_some_and(|seen_at| seen_at > activated_at) + ); + panel.handle_key_release(KeyEvent::new_with_kind( + KeyCode::Tab, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + assert_eq!(handoff.active_at(), None); + panel.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(panel.on_ctrl_c(), CancellationEvent::Handled); + assert!(!panel.handle_paste(" ignored".to_string())); + assert!(!panel.is_complete()); + assert!(rx.try_recv().is_err()); + + panel.update_config_panel(&ConfigPanelUpdate { + tui: Tui::default(), + keymap: RuntimeKeymap::defaults().list, + status: STATUS, + notice: None, + }); + panel.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(rx.try_recv().is_err()); + + panel.config_editor_opened(); + panel.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv().expect("reopened editor event"), + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::TerminalTitle + )); +} diff --git a/codex-rs/tui/src/config_panel/input.rs b/codex-rs/tui/src/config_panel/input.rs new file mode 100644 index 0000000000..d14bf0ad69 --- /dev/null +++ b/codex-rs/tui/src/config_panel/input.rs @@ -0,0 +1,186 @@ +//! Delegate navigation and selection to the shared list picker. + +use super::ConfigEditorHandoff; +use super::ConfigPanel; +use super::ConfigPanelUpdate; +use super::ConfigPreference; +use super::ConfigSection; +use super::ConfigSettingValue; +use super::PickerState; +use super::settings; +use crate::app_event::AppEvent; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; + +impl ConfigPanel { + fn setting_index(&self, selected: usize) -> Option { + let tab = self + .picker + .active_tab_id() + .unwrap_or(ConfigSection::Appearance.title()); + self.settings + .iter() + .enumerate() + .filter(|(_, setting)| setting.section.title() == tab) + .nth(selected) + .map(|(index, _)| index) + } + + fn activate(&mut self, selected: usize, activation_key: crate::key_hint::KeyBinding) { + if self.saving || self.editor_opening.is_some() { + return; + } + let Some(index) = self.setting_index(selected) else { + return; + }; + match &self.settings[index].value { + ConfigSettingValue::Toggle { setting, enabled } + | ConfigSettingValue::DependentToggle { + setting, + enabled, + dependency_met: true, + .. + } => { + self.saving = true; + self.app_event_tx + .send(AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: *setting, + enabled: !*enabled, + })); + } + ConfigSettingValue::Editor { editor, .. } => { + let handoff = ConfigEditorHandoff::new(*editor, activation_key); + self.editor_opening = Some(handoff.clone()); + self.app_event_tx.send(AppEvent::OpenConfigEditor(handoff)); + } + ConfigSettingValue::NotificationTest => { + self.app_event_tx.send(AppEvent::TestConfigNotification); + } + ConfigSettingValue::DependentToggle { + dependency_met: false, + .. + } + | ConfigSettingValue::Unavailable { .. } => {} + } + } + + fn observe_pending_handoff_key(&self, activation_key: crate::key_hint::KeyBinding) -> bool { + let Some(handoff) = &self.editor_opening else { + return false; + }; + if handoff.activation_key.is_same_physical_key(activation_key) { + handoff.mark_activation_seen(); + } else { + handoff.mark_activation_inactive(); + } + true + } +} + +impl BottomPaneView for ConfigPanel { + fn update_config_panel(&mut self, update: &ConfigPanelUpdate) -> bool { + let state = PickerState { + tab: self.picker.active_tab_id().map(str::to_string), + selected: self.picker.selected_index(), + query: self.picker.search_query().map(str::to_string), + notice: update.notice.clone(), + }; + self.settings = settings(&update.tui, update.status); + self.saving = false; + self.picker = super::render::picker( + &self.settings, + self.app_event_tx.clone(), + update.keymap.clone(), + state, + ); + true + } + + fn keymap_contexts(&self) -> KeymapContextSet { + KeymapContextSet::new(KeymapContext::List) + } + + fn accepts_input_when_disconnected(&self) -> bool { + true + } + + fn config_editor_opened(&mut self) { + self.editor_opening = None; + } + + fn interrupt_config_editor_transition(&mut self) { + if let Some(handoff) = &self.editor_opening { + handoff.mark_activation_inactive(); + } + } + + fn handle_key_event(&mut self, key: KeyEvent) { + self.handle_key_event_with_activation_key(key, /*activation_key*/ None); + } + + fn handle_key_event_with_activation_key( + &mut self, + key: KeyEvent, + activation_key: Option, + ) { + let activation_key = + activation_key.unwrap_or_else(|| crate::key_hint::KeyBinding::from_event(key)); + if key.kind == KeyEventKind::Release { + return; + } + if self.observe_pending_handoff_key(activation_key) { + return; + } + self.picker.handle_key_event(key); + if let Some(selected) = self.picker.take_last_selected_index() { + self.activate(selected, activation_key); + } + } + + fn handle_key_release(&mut self, key: KeyEvent) { + let activation_key = crate::key_hint::KeyBinding::from_event(key); + if let Some(handoff) = &self.editor_opening + && handoff.activation_key.is_same_physical_key(activation_key) + { + handoff.mark_activation_inactive(); + } + } + + fn observe_key_event_while_covered( + &mut self, + _key: KeyEvent, + activation_key: crate::key_hint::KeyBinding, + ) { + self.observe_pending_handoff_key(activation_key); + } + + fn handle_paste(&mut self, text: String) -> bool { + if let Some(handoff) = &self.editor_opening { + handoff.mark_activation_inactive(); + false + } else { + self.picker.handle_paste(text) + } + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn is_complete(&self) -> bool { + self.picker.is_complete() + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if let Some(handoff) = &self.editor_opening { + handoff.mark_activation_inactive(); + CancellationEvent::Handled + } else { + self.picker.on_ctrl_c() + } + } +} diff --git a/codex-rs/tui/src/config_panel/mod.rs b/codex-rs/tui/src/config_panel/mod.rs new file mode 100644 index 0000000000..0f1e96655d --- /dev/null +++ b/codex-rs/tui/src/config_panel/mod.rs @@ -0,0 +1,492 @@ +//! Catalog of TUI configuration editors and directly editable preferences shown by `/config`. +//! +//! This module defines the stable grouping, copy, search terms, and displayed values. Rendering +//! and interaction live in later modules so they can consume one ordered catalog. + +mod input; +mod render; +mod toggle; +pub(crate) use render::copy_on_select; +pub(crate) use render::notification_condition; +pub(crate) use render::resume_directory; +pub(crate) use render::right_click_paste; +pub(crate) use render::session_picker_layout; +pub(crate) use toggle::ConfigPreference; +pub(crate) use toggle::ConfigToggle; + +#[cfg(test)] +#[path = "config_panel_tests.rs"] +mod tests; + +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ListSelectionView; +use crate::key_hint::KeyBinding; +use crate::keymap::ListKeymap; +use codex_config::types::CopyOnSelect; +use codex_config::types::NotificationCondition; +use codex_config::types::Notifications; +use codex_config::types::RightClickPaste; +use codex_config::types::Tui; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Instant; + +const RIGHT_CLICK_PASTE_DESCRIPTION: &str = + "Paste when nothing is selected. Auto follows the platform default; On also enables macOS."; + +pub(crate) struct ConfigPanelUpdate { + pub(crate) tui: Tui, + pub(crate) keymap: ListKeymap, + pub(crate) status: ConfigPanelStatus, + pub(crate) notice: Option, +} + +#[derive(Default)] +struct PickerState { + tab: Option, + selected: Option, + query: Option, + notice: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigEditor { + Theme, + StatusLine, + TerminalTitle, + Keyboard, + SessionPickerLayout, + ResumeDirectory, + CopyOnSelect, + RightClickPaste, + NotificationCondition, +} + +#[derive(Clone, Copy)] +pub(crate) enum ResumeCurrentAvailability { + Available, + RequiresCwdOverride, +} + +/// Effective state the panel cannot derive from the `Tui` preferences alone. +#[derive(Clone, Copy)] +pub(crate) struct ConfigPanelStatus { + pub(crate) copy_on_select: bool, + pub(crate) right_click_paste: Result, + pub(crate) custom_notification_filters: bool, +} + +/// State shared by the panel that queued an editor and the event that will open it. +#[derive(Clone, Debug)] +pub(crate) struct ConfigEditorHandoff { + pub(crate) editor: ConfigEditor, + pub(crate) activation_key: KeyBinding, + activation: Arc>, +} + +#[derive(Debug)] +struct ConfigEditorActivation { + active: bool, + last_seen_at: Instant, +} + +impl ConfigEditorHandoff { + pub(crate) fn new(editor: ConfigEditor, activation_key: KeyBinding) -> Self { + Self { + editor, + activation_key, + activation: Arc::new(Mutex::new(ConfigEditorActivation { + active: true, + last_seen_at: Instant::now(), + })), + } + } + + pub(crate) fn mark_activation_seen(&self) { + self.mark_activation_seen_at(Instant::now()); + } + + fn mark_activation_seen_at(&self, seen_at: Instant) { + let mut activation = self + .activation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if activation.active { + activation.last_seen_at = activation.last_seen_at.max(seen_at); + } + } + + pub(crate) fn mark_activation_inactive(&self) { + self.activation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active = false; + } + + pub(crate) fn active_at(&self) -> Option { + let activation = self + .activation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + activation.active.then_some(activation.last_seen_at) + } +} + +impl ConfigEditor { + fn value(self, tui: &Tui, status: ConfigPanelStatus) -> ConfigSettingValue { + let label = match self { + Self::Theme => tui.theme.clone().unwrap_or_else(|| "Automatic".to_string()), + Self::SessionPickerLayout => match tui.session_picker_view.unwrap_or_default() { + codex_config::types::SessionPickerViewMode::Comfortable => "Comfortable", + codex_config::types::SessionPickerViewMode::Dense => "Dense", + } + .to_string(), + Self::ResumeDirectory => match tui.resume_cwd { + None => "Ask", + Some(codex_config::types::ResumeCwdMode::Current) => "Current directory", + Some(codex_config::types::ResumeCwdMode::Session) => "Session directory", + } + .to_string(), + Self::CopyOnSelect => match tui.copy_on_select { + CopyOnSelect::Auto => format!( + "Auto · {}", + if status.copy_on_select { "On" } else { "Off" } + ), + CopyOnSelect::Always => "On".to_string(), + CopyOnSelect::Never => "Off".to_string(), + }, + Self::RightClickPaste => { + let preference = match tui.right_click_paste { + RightClickPaste::Auto => "Auto", + RightClickPaste::On => "On", + RightClickPaste::Off => "Off", + }; + match status.right_click_paste { + Ok(enabled) if tui.right_click_paste == RightClickPaste::Auto => { + format!("Auto · {}", if enabled { "On" } else { "Off" }) + } + Ok(_) => preference.to_string(), + Err(_) => format!("{preference} · Unavailable"), + } + } + Self::NotificationCondition => match tui.notification_settings.condition { + NotificationCondition::Unfocused => "Unfocused", + NotificationCondition::Always => "Always", + } + .to_string(), + Self::StatusLine | Self::TerminalTitle | Self::Keyboard => "Configure".to_string(), + }; + ConfigSettingValue::Editor { + label, + editor: self, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConfigSection { + Appearance, + Notifications, + Input, + Sessions, + TextSelection, +} + +impl ConfigSection { + const ALL: [Self; 5] = [ + Self::Appearance, + Self::Notifications, + Self::Input, + Self::Sessions, + Self::TextSelection, + ]; + + fn title(self) -> &'static str { + match self { + Self::Appearance => "Appearance", + Self::Notifications => "Notifications", + Self::Input => "Input", + Self::Sessions => "Sessions", + Self::TextSelection => "Text selection", + } + } +} + +struct ConfigSetting { + section: ConfigSection, + label: &'static str, + description: &'static str, + keywords: &'static str, + value: ConfigSettingValue, +} + +impl ConfigSetting { + fn display_label(&self) -> String { + if matches!(self.value, ConfigSettingValue::DependentToggle { .. }) { + format!(" {}", self.label) + } else { + self.label.to_string() + } + } +} + +enum ConfigSettingValue { + Toggle { + setting: ConfigToggle, + enabled: bool, + }, + Editor { + label: String, + editor: ConfigEditor, + }, + DependentToggle { + setting: ConfigToggle, + enabled: bool, + dependency_met: bool, + unavailable_reason: &'static str, + }, + NotificationTest, + Unavailable { + reason: &'static str, + }, +} + +impl ConfigSettingValue { + fn label(&self) -> String { + match self { + Self::Toggle { enabled, .. } + | Self::DependentToggle { + enabled, + dependency_met: true, + .. + } => if *enabled { "[x] On" } else { "[ ] Off" }.to_string(), + Self::Editor { label, .. } => format!("{label} ›"), + Self::NotificationTest => "Send in 5s ›".to_string(), + Self::DependentToggle { + dependency_met: false, + .. + } + | Self::Unavailable { .. } => "Unavailable".to_string(), + } + } +} + +fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { + let notifications_enabled = !matches!( + tui.notification_settings.notifications, + Notifications::Enabled(false) + ); + vec![ + ConfigSetting { + section: ConfigSection::Appearance, + label: "Theme", + description: "Choose a syntax color palette with a live preview.", + keywords: "colors dark light palette", + value: ConfigEditor::Theme.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::Appearance, + label: "Animations", + description: "Animate the welcome screen, working indicator, and transitions. Restart Codex to apply.", + keywords: "motion accessibility spinner", + value: ConfigSettingValue::Toggle { + setting: ConfigToggle::Animations, + enabled: ConfigToggle::Animations.enabled(tui), + }, + }, + ConfigSetting { + section: ConfigSection::Appearance, + label: "Composer stars", + description: "Animate stars in the composer. Requires animations and a restart.", + keywords: "effects starfield stars motion", + value: ConfigSettingValue::DependentToggle { + setting: ConfigToggle::ComposerStars, + enabled: ConfigToggle::ComposerStars.enabled(tui), + dependency_met: tui.animations, + unavailable_reason: "Turn on animations, then restart Codex to enable composer stars.", + }, + }, + ConfigSetting { + section: ConfigSection::Appearance, + label: "Tips", + description: "Show helpful shortcuts and suggestions. Restart Codex to apply.", + keywords: "welcome hints tooltips", + value: ConfigSettingValue::Toggle { + setting: ConfigToggle::Tips, + enabled: ConfigToggle::Tips.enabled(tui), + }, + }, + ConfigSetting { + section: ConfigSection::Appearance, + label: "Status line colors", + description: "Use theme colors for the model, branch, and other status line fields.", + keywords: "statusline footer colour", + value: ConfigSettingValue::Toggle { + setting: ConfigToggle::StatusLineColors, + enabled: ConfigToggle::StatusLineColors.enabled(tui), + }, + }, + ConfigSetting { + section: ConfigSection::Appearance, + label: "Status line fields", + description: "Choose and reorder the information below the composer.", + keywords: "statusline footer model branch context", + value: ConfigEditor::StatusLine.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::Appearance, + label: "Terminal title fields", + description: "Choose and reorder the information in your terminal tab or window title.", + keywords: "title tab window name", + value: ConfigEditor::TerminalTitle.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::Notifications, + label: "Notifications", + description: "Let your terminal notify you when a turn finishes or approval is needed.", + keywords: "alerts bell completion approval", + value: if status.custom_notification_filters { + ConfigSettingValue::Unavailable { + reason: "Event-specific notifications are configured. Edit them in config.toml.", + } + } else { + ConfigSettingValue::Toggle { + setting: ConfigToggle::Notifications, + enabled: notifications_enabled, + } + }, + }, + ConfigSetting { + section: ConfigSection::Notifications, + label: "Notify when", + description: "Only notify while you are away, or allow notifications while this terminal is focused too.", + keywords: "focus alerts background always unfocused", + value: if notifications_enabled { + ConfigEditor::NotificationCondition.value(tui, status) + } else { + ConfigSettingValue::Unavailable { + reason: "Turn on notifications to choose when they appear.", + } + }, + }, + ConfigSetting { + section: ConfigSection::Notifications, + label: "Test notification", + description: "Sends in 5 seconds. Switch apps: your terminal may hide notifications while focused.", + keywords: "alerts bell sound test", + value: ConfigSettingValue::NotificationTest, + }, + ConfigSetting { + section: ConfigSection::Input, + label: "Keyboard shortcuts", + description: "Browse actions and remap their keys.", + keywords: "keymap bindings hotkeys", + value: ConfigEditor::Keyboard.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::Input, + label: "Start in Vim mode", + description: "Applies to new composers. Use /vim to switch the current composer.", + keywords: "editor modal normal", + value: ConfigSettingValue::Toggle { + setting: ConfigToggle::StartInVimMode, + enabled: ConfigToggle::StartInVimMode.enabled(tui), + }, + }, + ConfigSetting { + section: ConfigSection::Input, + label: "Paste detection", + description: "Recognize pasted-text bursts and keep multiline input together. Restart Codex to apply.", + keywords: "burst clipboard multiline", + value: ConfigSettingValue::Toggle { + setting: ConfigToggle::PasteDetection, + enabled: ConfigToggle::PasteDetection.enabled(tui), + }, + }, + ConfigSetting { + section: ConfigSection::Sessions, + label: "Automatic recaps", + description: "Prepare recaps while the terminal is unfocused. Restart Codex to apply.", + keywords: "summary background", + value: ConfigSettingValue::Toggle { + setting: ConfigToggle::AutomaticRecaps, + enabled: ConfigToggle::AutomaticRecaps.enabled(tui), + }, + }, + ConfigSetting { + section: ConfigSection::Sessions, + label: "Session picker layout", + description: "Choose tighter rows or a roomier list when resuming and forking conversations.", + keywords: "dense comfortable spacing resume fork", + value: ConfigEditor::SessionPickerLayout.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::Sessions, + label: "Resume directory", + description: "Choose which working directory to use when the current directory and saved session differ.", + keywords: "cwd folder path fork ask", + value: ConfigEditor::ResumeDirectory.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::TextSelection, + label: "Copy on select", + description: "Copy transcript selections on release. Auto uses terminal defaults.", + keywords: "clipboard mouse selection automatic drag", + value: ConfigEditor::CopyOnSelect.value(tui, status), + }, + ConfigSetting { + section: ConfigSection::TextSelection, + label: "Deselect after copy", + description: "Remove the selection highlight after Copy on select succeeds.", + keywords: "clipboard deselect clear highlight automatic mouse selection", + value: ConfigSettingValue::DependentToggle { + setting: ConfigToggle::CopyOnSelectClearSelection, + enabled: ConfigToggle::CopyOnSelectClearSelection.enabled(tui), + dependency_met: status.copy_on_select, + unavailable_reason: "Turn on Copy on select to deselect after copying.", + }, + }, + ConfigSetting { + section: ConfigSection::TextSelection, + label: "Right-click paste", + description: status + .right_click_paste + .err() + .unwrap_or(RIGHT_CLICK_PASTE_DESCRIPTION), + keywords: "clipboard mouse paste automatic", + value: ConfigEditor::RightClickPaste.value(tui, status), + }, + ] +} + +pub(crate) struct ConfigPanel { + settings: Vec, + picker: ListSelectionView, + saving: bool, + editor_opening: Option, + app_event_tx: AppEventSender, +} + +impl ConfigPanel { + pub(crate) fn new( + tui: &Tui, + app_event_tx: AppEventSender, + keymap: ListKeymap, + status: ConfigPanelStatus, + ) -> Self { + let settings = settings(tui, status); + let picker = render::picker( + &settings, + app_event_tx.clone(), + keymap, + PickerState::default(), + ); + Self { + settings, + picker, + saving: false, + editor_opening: None, + app_event_tx, + } + } +} diff --git a/codex-rs/tui/src/config_panel/render.rs b/codex-rs/tui/src/config_panel/render.rs new file mode 100644 index 0000000000..a906b96f68 --- /dev/null +++ b/codex-rs/tui/src/config_panel/render.rs @@ -0,0 +1,405 @@ +//! Render the configuration catalog and its child choice picker. + +use super::ConfigPanel; +use super::ConfigPreference; +use super::ConfigSection; +use super::ConfigSetting; +use super::ConfigSettingValue; +use super::PickerState; +use super::ResumeCurrentAvailability; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::ListSelectionView; +use crate::bottom_pane::PickerSurface; +use crate::bottom_pane::SearchMode; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::footer_hint_items_line; +use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap; +use crate::key_hint::KeyBinding; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::renderable::Renderable; +use crate::wrapping::word_wrap_lines; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use unicode_width::UnicodeWidthStr; + +pub(crate) fn notification_condition( + tui: &codex_config::types::Tui, + keymap: &ListKeymap, +) -> SelectionViewParams { + use codex_config::types::NotificationCondition; + + let current = tui.notification_settings.condition; + let conditions = [ + ("Unfocused", NotificationCondition::Unfocused), + ("Always", NotificationCondition::Always), + ]; + SelectionViewParams { + title: Some("Notify when".to_string()), + subtitle: Some( + "Only notify while you are away, or allow notifications while this terminal is focused too." + .to_string(), + ), + footer_hint: Some(picker_hint_line_for_keymap(keymap)), + initial_selected_idx: conditions + .iter() + .position(|(_, condition)| *condition == current), + allow_input_when_disconnected: true, + items: conditions + .into_iter() + .map(|(label, condition)| SelectionItem { + name: label.to_string(), + is_current: condition == current, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveConfigPreference( + ConfigPreference::NotificationCondition(condition), + )); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..SelectionViewParams::picker() + } +} + +pub(crate) fn copy_on_select( + tui: &codex_config::types::Tui, + keymap: &ListKeymap, +) -> SelectionViewParams { + use codex_config::types::CopyOnSelect; + + let current = tui.copy_on_select; + let modes = [ + ("Auto (default)", CopyOnSelect::Auto), + ("On", CopyOnSelect::Always), + ("Off", CopyOnSelect::Never), + ]; + SelectionViewParams { + title: Some("Copy on select".to_string()), + subtitle: Some( + "Copy transcript selections on release. Auto uses terminal defaults.".to_string(), + ), + footer_hint: Some(picker_hint_line_for_keymap(keymap)), + initial_selected_idx: modes.iter().position(|(_, mode)| *mode == current), + allow_input_when_disconnected: true, + items: modes + .into_iter() + .map(|(label, mode)| SelectionItem { + name: label.to_string(), + is_current: mode == current, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveConfigPreference( + ConfigPreference::CopyOnSelect(mode), + )); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..SelectionViewParams::picker() + } +} + +pub(crate) fn right_click_paste( + tui: &codex_config::types::Tui, + keymap: &ListKeymap, +) -> SelectionViewParams { + use codex_config::types::RightClickPaste; + + let current = tui.right_click_paste; + let modes = [ + ("Auto (default)", RightClickPaste::Auto), + ("On", RightClickPaste::On), + ("Off", RightClickPaste::Off), + ]; + SelectionViewParams { + title: Some("Right-click paste".to_string()), + subtitle: Some(super::RIGHT_CLICK_PASTE_DESCRIPTION.to_string()), + footer_hint: Some(picker_hint_line_for_keymap(keymap)), + initial_selected_idx: modes.iter().position(|(_, mode)| *mode == current), + allow_input_when_disconnected: true, + items: modes + .into_iter() + .map(|(label, mode)| SelectionItem { + name: label.to_string(), + is_current: mode == current, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveConfigPreference( + ConfigPreference::RightClickPaste(mode), + )); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..SelectionViewParams::picker() + } +} + +pub(crate) fn session_picker_layout( + tui: &codex_config::types::Tui, + keymap: &ListKeymap, +) -> SelectionViewParams { + use codex_config::types::SessionPickerViewMode; + + let current = tui.session_picker_view.unwrap_or_default(); + let modes = [ + ("Dense", SessionPickerViewMode::Dense), + ("Comfortable", SessionPickerViewMode::Comfortable), + ]; + SelectionViewParams { + title: Some("Session picker layout".to_string()), + subtitle: Some( + "Choose tighter rows or a roomier list when resuming and forking conversations." + .to_string(), + ), + footer_hint: Some(picker_hint_line_for_keymap(keymap)), + initial_selected_idx: modes.iter().position(|(_, mode)| *mode == current), + allow_input_when_disconnected: true, + items: modes + .into_iter() + .map(|(label, mode)| SelectionItem { + name: label.to_string(), + is_current: mode == current, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveConfigPreference( + ConfigPreference::SessionPickerLayout(mode), + )); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..SelectionViewParams::picker() + } +} + +pub(crate) fn resume_directory( + tui: &codex_config::types::Tui, + keymap: &ListKeymap, + current_availability: ResumeCurrentAvailability, +) -> SelectionViewParams { + use codex_config::types::ResumeCwdMode; + + let current = tui.resume_cwd; + let modes = [ + ("Ask", None), + ("Current directory", Some(ResumeCwdMode::Current)), + ("Session directory", Some(ResumeCwdMode::Session)), + ]; + SelectionViewParams { + title: Some("Resume directory".to_string()), + subtitle: Some( + "Choose which working directory to use when the current directory and saved session differ. A directory passed with --cd takes precedence for that Codex session." + .to_string(), + ), + footer_hint: Some(picker_hint_line_for_keymap(keymap)), + initial_selected_idx: modes.iter().position(|(_, mode)| *mode == current), + allow_input_when_disconnected: true, + items: modes + .into_iter() + .map(|(label, mode)| SelectionItem { + name: label.to_string(), + is_current: mode == current, + disabled_reason: if mode == Some(ResumeCwdMode::Current) + && matches!( + current_availability, + ResumeCurrentAvailability::RequiresCwdOverride + ) + { + Some("Requires --cd for remote sessions.".to_string()) + } else { + None + }, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveConfigPreference( + ConfigPreference::ResumeDirectory(mode), + )); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..SelectionViewParams::picker() + } +} + +struct ConfigPanelHeader(Vec>); + +impl Renderable for ConfigPanelHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + Renderable::render( + &Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))), + area, + buf, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + word_wrap_lines(&self.0, usize::from(width)).len() as u16 + } +} + +fn header(summary: String) -> Box { + Box::new(ConfigPanelHeader(vec![ + Line::from("Configuration".bold()), + Line::from("Changes save automatically; some apply after restarting Codex.".dim()), + Line::from(summary.dim()), + ])) +} + +fn hint(keymap: &ListKeymap, search: Option) -> Line<'static> { + let key_label = |action| { + keymap.primary_hint(action).map(|hint| { + hint.display_label() + .replace('←', "left") + .replace('→', "right") + }) + }; + let mut items = Vec::new(); + let keys = [ListAction::MoveLeft, ListAction::MoveRight] + .into_iter() + .filter_map(key_label) + .collect::>() + .join("/"); + if !keys.is_empty() { + items.push((keys, "group".into())); + } + if let Some(search) = search { + items.push((search.display_label(), "search".into())); + } + items.extend( + [ + (ListAction::Accept, "edit setting"), + (ListAction::Cancel, "close"), + ] + .into_iter() + .filter_map(|(action, label)| key_label(action).map(|key| (key, label.into()))), + ); + footer_hint_items_line(&items) +} + +pub(super) fn picker( + settings: &[ConfigSetting], + app_event_tx: AppEventSender, + keymap: ListKeymap, + state: PickerState, +) -> ListSelectionView { + let summary = |count| { + state + .notice + .clone() + .unwrap_or_else(|| format!("Settings in this group: {count}.")) + }; + let search = crate::key_hint::plain(KeyCode::Char('/')); + let (search_code, search_modifiers) = search.parts(); + let search_event = KeyEvent::new(search_code, search_modifiers); + let search_hint = (keymap.action_for(search_event).is_none() + && !keymap.has_chord_prefix(search_event)) + .then_some(search); + let mut params = SelectionViewParams { + picker_surface: PickerSurface::Panel, + max_visible_rows: ConfigSection::ALL + .iter() + .map(|section| { + settings + .iter() + .filter(|setting| setting.section == *section) + .count() + }) + .max() + .unwrap_or_default(), + reserve_result_rows: true, + row_display: SelectionRowDisplay::SingleLine, + col_width_mode: ColumnWidthMode::AutoAllRows, + footer_hint: Some(hint(&keymap, search_hint)), + is_searchable: true, + search_mode: SearchMode::OnDemand(search), + search_placeholder: Some("Type to search settings".to_string()), + name_column_width: settings + .iter() + .map(|setting| UnicodeWidthStr::width(setting.label)) + .max(), + tabs: ConfigSection::ALL + .iter() + .map(|section| { + let group = settings + .iter() + .filter(|setting| setting.section == *section) + .collect::>(); + let title = section.title(); + SelectionTab { + id: title.to_string(), + label: title.to_string(), + header: header(summary(group.len())), + items: group + .into_iter() + .map(|setting| { + let unavailable = match &setting.value { + ConfigSettingValue::DependentToggle { + dependency_met: false, + unavailable_reason, + .. + } => Some(*unavailable_reason), + ConfigSettingValue::Unavailable { reason } => Some(*reason), + ConfigSettingValue::Toggle { .. } + | ConfigSettingValue::DependentToggle { .. } + | ConfigSettingValue::Editor { .. } + | ConfigSettingValue::NotificationTest => None, + }; + let value_label = setting.value.label(); + SelectionItem { + name: setting.display_label(), + description: Some(format!( + "{:<19} {}", + value_label, + unavailable.unwrap_or(setting.description) + )), + is_disabled: unavailable.is_some(), + search_value: Some(format!( + "{} {} {} {} {}", + section.title(), + setting.label, + value_label, + setting.keywords, + setting.description + )), + ..Default::default() + } + }) + .collect(), + } + }) + .collect(), + ..Default::default() + }; + params.initial_selected_idx = state.selected; + params.initial_search_query = state.query; + params.initial_tab_id = state.tab; + ListSelectionView::new(params, app_event_tx, keymap) +} + +impl Renderable for ConfigPanel { + fn desired_height(&self, width: u16) -> u16 { + self.picker.desired_height(width) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.picker.render(area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.picker.cursor_pos(area) + } +} diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap new file mode 100644 index 0000000000..c38ceb2ad6 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 61 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 7. + + Appearance Notifications Input Sessions Text selection + +› Theme Automatic › Choose a syntax color palette with a live preview. + Animations [ ] Off Animate the welcome screen, working indicator, and transitions. Restart Codex to apply. + Composer stars Unavailable Turn on animations, then restart Codex to enable composer stars. + Tips [ ] Off Show helpful shortcuts and suggestions. Restart Codex to apply. + Status line colors [ ] Off Use theme colors for the model, branch, and other status line fields. + Status line fields Configure › Choose and reorder the information below the composer. + Terminal title fields Configure › Choose and reorder the information in your terminal tab or window title. + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap new file mode 100644 index 0000000000..4f5fe9f595 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 61 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + +› Keyboard shortcuts Configure › Browse actions and remap their keys. + Start in Vim mode [ ] Off Applies to new composers. Use /vim to switch the current composer. + Paste detection [x] On Recognize pasted-text bursts and keep multiline input together. Restart Codex to apply. + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap new file mode 100644 index 0000000000..01d2a8bf12 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 73 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + +› Notifications [x] On Let your terminal notify you when a turn finishes or approval is needed. + Notify when Unfocused › Only notify while you are away, or allow notifications while this terminal is focused too. + Test notification Send in 5s › Sends in 5 seconds. Switch apps: your terminal may hide notifications while focused. + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap new file mode 100644 index 0000000000..8e3ad4ee2b --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 61 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + +› Automatic recaps [ ] Off Prepare recaps while the terminal is unfocused. Restart Codex to apply. + Session picker layout Dense › Choose tighter rows or a roomier list when resuming and forking conversations. + Resume directory Ask › Choose which working directory to use when the current directory and saved session differ. + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap new file mode 100644 index 0000000000..df721cb00b --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 71 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + +› Copy on select Auto · On › Copy transcript selections on release. Auto uses terminal defaults. + Deselect after copy [ ] Off Remove the selection highlight after Copy on select succeeds. + Right-click paste Auto · Off › Paste when nothing is selected. Auto follows the platform default; On also enables macOS. + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap new file mode 100644 index 0000000000..8b7dd84ffb --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +expression: "render(&picker, 100)" +--- + Copy on select + Copy transcript selections on release. Auto uses terminal defaults. + + +› 1. Auto (default) (current) + 2. On + 3. Off + + enter select · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap new file mode 100644 index 0000000000..efdda55361 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +expression: "render(&picker, 100)" +--- + Notify when + Only notify while you are away, or allow notifications while this terminal is focused too. + + +› 1. Unfocused (current) + 2. Always + + enter select · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap new file mode 100644 index 0000000000..88ab7f1bb6 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 140 +expression: "render(&panel, 100)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + + Notifications Unavailable Event-specific notifications are configured. Edit them … +› Notify when Unfocused › Only notify while you are away, or allow notifications … + Test notification Send in 5s › Sends in 5 seconds. Switch apps: your terminal may hide… + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap new file mode 100644 index 0000000000..4552fad628 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 159 +expression: "render(&panel, 100)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + +› Notifications [ ] Off Let your terminal notify you when a turn finishes or ap… + Notify when Unavailable Turn on notifications to choose when they appear. + Test notification Send in 5s › Sends in 5 seconds. Switch apps: your terminal may hide… + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap new file mode 100644 index 0000000000..2aa334d3c5 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 227 +expression: "render(&panel, 50)" +--- + + Configuration + Changes save automatically; some apply after + restarting Codex. + Failed to save status line settings: + permission denied while writing config.toml + + Appearance Notifications Input › + +› Theme Automatic › Cho… + Animations [ ] Off Ani… + Composer stars Unavailable Tur… + Tips [ ] Off Sho… + Status line colors [ ] Off Use… + Status line fields Configure › Cho… + Terminal title fields Configure › Cho… + + left/right group · / search · enter edit setting + · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap new file mode 100644 index 0000000000..dbbcc1344d --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +expression: "render(&picker, 100)" +--- + + Resume directory + Choose which working directory to use when the current directory and saved session differ. A + directory passed with --cd takes precedence for that Codex session. + + +› 1. Ask (current) + Current directory (disabled) Requires --cd for remote sessions. + 2. Session directory + + enter select · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap new file mode 100644 index 0000000000..392e2a5655 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +expression: "render(&picker, 100)" +--- + Right-click paste + Paste when nothing is selected. Auto follows the platform default; On also enables macOS. + + +› 1. Auto (default) (current) + 2. On + 3. Off + + enter select · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap new file mode 100644 index 0000000000..5e55c3fb13 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 290 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + + Copy on select Auto · On › Copy transcript selections on release. Auto uses terminal defaults. + Deselect after copy [ ] Off Remove the selection highlight after Copy on select succeeds. +› Right-click paste On · Unavailable › Unavailable in inline mode. Use your terminal's paste command. + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap new file mode 100644 index 0000000000..daefa032bf --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +expression: "render(&picker, 100)" +--- + Session picker layout + Choose tighter rows or a roomier list when resuming and forking conversations. + + +› 1. Dense (current) + 2. Comfortable + + enter select · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap new file mode 100644 index 0000000000..10c5f38062 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 96 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Settings in this group: 3. + + Appearance Notifications Input Sessions Text selection + + Search › deselect after copy + +› Deselect after copy Unavailable Turn on Copy on select to deselect after copying. + + + + + + + + left/right group · / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap new file mode 100644 index 0000000000..ce877485a0 --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 212 +expression: "render(&panel, 140)" +--- + + Configuration + Changes save automatically; some apply after restarting Codex. + Saved · restart Codex to apply + + Appearance Notifications Input Sessions Text selection + + Search › animations + +› Animations [x] On Animate the welcome screen, working indicator, and transitions. Restart Codex to apply. + Composer stars [x] On Animate stars in the composer. Requires animations and a restart. + + + + + + + / search · enter edit setting · esc close diff --git a/codex-rs/tui/src/config_panel/toggle.rs b/codex-rs/tui/src/config_panel/toggle.rs new file mode 100644 index 0000000000..50d7618b3c --- /dev/null +++ b/codex-rs/tui/src/config_panel/toggle.rs @@ -0,0 +1,173 @@ +//! Maps config-panel preference changes to persisted and effective TUI values. + +use crate::legacy_core::config::edit::ConfigEdit; +use crate::legacy_core::config::edit::session_picker_view_edit; +use codex_config::types::CopyOnSelect; +use codex_config::types::NotificationCondition; +use codex_config::types::Notifications; +use codex_config::types::ResumeCwdMode; +use codex_config::types::RightClickPaste; +use codex_config::types::SessionPickerViewMode; +use codex_config::types::Tui; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigToggle { + Animations, + AutomaticRecaps, + ComposerStars, + CopyOnSelectClearSelection, + Notifications, + PasteDetection, + StartInVimMode, + StatusLineColors, + Tips, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigPreference { + Toggle { + setting: ConfigToggle, + enabled: bool, + }, + CopyOnSelect(CopyOnSelect), + NotificationCondition(NotificationCondition), + RightClickPaste(RightClickPaste), + SessionPickerLayout(SessionPickerViewMode), + ResumeDirectory(Option), +} + +impl ConfigPreference { + pub(crate) fn edit(self) -> ConfigEdit { + match self { + Self::Toggle { setting, enabled } => setting.edit(enabled), + Self::CopyOnSelect(mode) => ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "copy_on_select".to_string()], + value: toml_edit::value(match mode { + CopyOnSelect::Auto => "auto", + CopyOnSelect::Always => "always", + CopyOnSelect::Never => "never", + }), + }, + Self::NotificationCondition(condition) => ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "notification_condition".to_string()], + value: toml_edit::value(condition.to_string()), + }, + Self::RightClickPaste(mode) => ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "right_click_paste".to_string()], + value: toml_edit::value(match mode { + RightClickPaste::Auto => "auto", + RightClickPaste::On => "on", + RightClickPaste::Off => "off", + }), + }, + Self::SessionPickerLayout(mode) => session_picker_view_edit(mode), + Self::ResumeDirectory(None) => ConfigEdit::ClearPath { + segments: vec!["tui".to_string(), "resume_cwd".to_string()], + }, + Self::ResumeDirectory(Some(mode)) => ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "resume_cwd".to_string()], + value: toml_edit::value(mode.as_str()), + }, + } + } + + /// Copies only this preference, leaving unrelated settings unchanged. + pub(crate) fn adopt(self, target: &mut Tui, source: &Tui) { + match self { + Self::Toggle { setting, .. } => setting.adopt(target, source), + Self::CopyOnSelect(_) => target.copy_on_select = source.copy_on_select, + Self::NotificationCondition(_) => { + target.notification_settings.condition = source.notification_settings.condition; + } + Self::RightClickPaste(_) => target.right_click_paste = source.right_click_paste, + Self::SessionPickerLayout(_) => { + target.session_picker_view = source.session_picker_view; + } + Self::ResumeDirectory(_) => target.resume_cwd = source.resume_cwd, + } + } +} + +impl ConfigToggle { + const RESTART_REQUIRED: [Self; 5] = [ + Self::Animations, + Self::AutomaticRecaps, + Self::ComposerStars, + Self::PasteDetection, + Self::Tips, + ]; + + pub(crate) fn requires_restart(self) -> bool { + Self::RESTART_REQUIRED.contains(&self) + } + + pub(crate) fn preserve_restart_required(target: &mut Tui, source: &Tui) { + for setting in Self::RESTART_REQUIRED { + setting.adopt(target, source); + } + } + + pub(crate) fn edit(self, enabled: bool) -> ConfigEdit { + let (key, value) = match self { + Self::ComposerStars => { + return ConfigEdit::SetPath { + segments: vec!["tui".into(), "effects".into(), "starfield".into()], + value: toml_edit::value(enabled), + }; + } + Self::Notifications => { + return ConfigEdit::SetTuiNotificationsEnabled { enabled }; + } + Self::Animations => ("animations", enabled), + Self::AutomaticRecaps => ("auto_recap", enabled), + Self::CopyOnSelectClearSelection => ("copy_on_select_clear_selection", enabled), + Self::PasteDetection => ("disable_paste_burst", !enabled), + Self::StartInVimMode => ("vim_mode_default", enabled), + Self::StatusLineColors => ("status_line_use_colors", enabled), + Self::Tips => ("show_tooltips", enabled), + }; + ConfigEdit::SetPath { + segments: vec!["tui".into(), key.into()], + value: toml_edit::value(value), + } + } + + pub(crate) fn enabled(self, tui: &Tui) -> bool { + match self { + Self::Animations => tui.animations, + Self::AutomaticRecaps => tui.auto_recap, + Self::ComposerStars => tui.effects.starfield, + Self::CopyOnSelectClearSelection => tui.copy_on_select_clear_selection, + Self::Notifications => !matches!( + tui.notification_settings.notifications, + Notifications::Enabled(false) + ), + Self::PasteDetection => !tui.disable_paste_burst.unwrap_or(/*default*/ false), + Self::StartInVimMode => tui.vim_mode_default, + Self::StatusLineColors => tui.status_line_use_colors, + Self::Tips => tui.show_tooltips, + } + } + + /// Copies only this preference, leaving unrelated settings unchanged. + pub(crate) fn adopt(self, target: &mut Tui, source: &Tui) { + match self { + Self::Animations => target.animations = source.animations, + Self::AutomaticRecaps => target.auto_recap = source.auto_recap, + Self::ComposerStars => target.effects.starfield = source.effects.starfield, + Self::CopyOnSelectClearSelection => { + target.copy_on_select_clear_selection = source.copy_on_select_clear_selection; + } + Self::Notifications => { + target.notification_settings.notifications = + source.notification_settings.notifications.clone(); + } + Self::PasteDetection => target.disable_paste_burst = source.disable_paste_burst, + Self::StartInVimMode => target.vim_mode_default = source.vim_mode_default, + Self::StatusLineColors => { + target.status_line_use_colors = source.status_line_use_colors; + } + Self::Tips => target.show_tooltips = source.show_tooltips, + } + } +} diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 4460b50a8b..001d401046 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -145,6 +145,17 @@ impl KeyBinding { } } + /// Return whether two events came from the same held key. + /// + /// Shift can change Tab into BackTab while it is held, so both codes represent Tab here. + pub(crate) fn is_same_physical_key(self, other: Self) -> bool { + let physical_code = |binding: Self| match binding.normalized_parts().0 { + KeyCode::BackTab => KeyCode::Tab, + code => code, + }; + physical_code(self) == physical_code(other) + } + pub(crate) fn spans(&self) -> Vec> { vec![self.into()] } @@ -154,6 +165,10 @@ pub(crate) fn normalize_key_parts( key: KeyCode, mut modifiers: KeyModifiers, ) -> (KeyCode, KeyModifiers) { + if key == KeyCode::BackTab || (key == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT)) { + modifiers.remove(KeyModifiers::SHIFT); + return (KeyCode::BackTab, modifiers); + } let KeyCode::Char(ch) = key else { return (key, modifiers); }; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 8768364f08..841b3fef70 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -413,6 +413,13 @@ impl ListKeymap { .find(|action| self.bindings_for(*action).is_pressed(event)) } + pub(crate) fn has_chord_prefix(&self, event: KeyEvent) -> bool { + self.chord_hints.bindings.iter().any(|binding| { + binding.action.context.overlaps(KeymapContext::List) + && binding.chord.prefix.is_press(event) + }) + } + pub(crate) fn primary_hint(&self, action: ListAction) -> Option { let action_id = keymap_action_id(KeymapContext::List.config_name(), action.config_name())?; self.chord_hints diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 24adb25994..fc6855fba5 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -394,23 +394,29 @@ Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings." action: KeymapActionId, bindings: &[KeyBinding], ) -> Option { - if let Some(spec) = self.configured_specs(action).and_then(|specs| { - specs - .iter() - .find(|spec| !spec.starts_with("leader ") || !self.leader.is_empty()) + if let Some(hint) = self.configured_specs(action).and_then(|specs| { + specs.iter().find_map(|spec| { + if let Some((prefix, completion)) = spec.split_once(' ') { + Some(crate::key_hint::ShortcutHint::Chord { + prefix: if prefix == "leader" { + *self.leader.first()? + } else { + parse_keybinding(prefix)? + }, + completion: parse_keybinding(completion)?, + }) + } else { + parse_keybinding(spec) + .filter(|configured| { + bindings.iter().any(|binding| { + binding.normalized_parts() == configured.normalized_parts() + }) + }) + .map(crate::key_hint::ShortcutHint::Single) + } + }) }) { - return if let Some((prefix, completion)) = spec.split_once(' ') { - Some(crate::key_hint::ShortcutHint::Chord { - prefix: if prefix == "leader" { - *self.leader.first()? - } else { - parse_keybinding(prefix)? - }, - completion: parse_keybinding(completion)?, - }) - } else { - parse_keybinding(spec).map(crate::key_hint::ShortcutHint::Single) - }; + return Some(hint); } super::primary_binding(bindings) diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 3aa04ae215..e9bba1e9dd 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -325,6 +325,7 @@ pub(crate) fn build_keymap_action_menu_params( }); SelectionViewParams { + allow_input_when_disconnected: true, view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID), header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), footer_note: Some(Line::from(vec![ @@ -392,6 +393,7 @@ pub(crate) fn build_keymap_replace_binding_menu_params( .collect(); SelectionViewParams { + allow_input_when_disconnected: true, view_id: Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID), header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), footer_hint: Some(picker_hint_line_for_keymap(&runtime_keymap.list)), @@ -416,6 +418,7 @@ pub(crate) fn build_keymap_conflict_params( KeymapCaptureMode::SingleKey }; SelectionViewParams { + allow_input_when_disconnected: true, header: Box::new( Paragraph::new(vec![ Line::from("Shortcut Conflict".bold()), @@ -675,6 +678,10 @@ fn key_parts_to_config_key_spec( let key = match code { KeyCode::Enter => "enter".to_string(), KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => { + modifiers.insert(KeyModifiers::SHIFT); + "tab".to_string() + } KeyCode::Backspace => "backspace".to_string(), KeyCode::Esc => "esc".to_string(), KeyCode::Delete => "delete".to_string(), @@ -1766,6 +1773,14 @@ mod tests { key_event_to_config_key_spec(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)), Ok("shift-page-down".to_string()) ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::from(KeyCode::BackTab)), + Ok("shift-tab".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)), + Ok("shift-tab".to_string()) + ); } #[test] diff --git a/codex-rs/tui/src/keymap_setup/capture.rs b/codex-rs/tui/src/keymap_setup/capture.rs index 07689cfad7..9f9af5d5d2 100644 --- a/codex-rs/tui/src/keymap_setup/capture.rs +++ b/codex-rs/tui/src/keymap_setup/capture.rs @@ -137,6 +137,10 @@ impl BottomPaneView for KeymapCaptureView { crate::keymap::KeymapContextSet::raw_key_capture() } + fn accepts_input_when_disconnected(&self) -> bool { + true + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release || (self.capture_mode == KeymapCaptureMode::Chord diff --git a/codex-rs/tui/src/keymap_setup/debug.rs b/codex-rs/tui/src/keymap_setup/debug.rs index 0bcd64d6a2..0415086a56 100644 --- a/codex-rs/tui/src/keymap_setup/debug.rs +++ b/codex-rs/tui/src/keymap_setup/debug.rs @@ -154,6 +154,10 @@ impl BottomPaneView for KeymapDebugView { crate::keymap::KeymapContextSet::raw_key_capture() } + fn accepts_input_when_disconnected(&self) -> bool { + true + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index e962b3cace..975a06c1b2 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -297,6 +297,7 @@ fn build_keymap_picker_params_for_action( tabs.push(keymap_debug_tab()); SelectionViewParams { + allow_input_when_disconnected: true, view_id: Some(KEYMAP_PICKER_VIEW_ID), picker_surface: PickerSurface::Panel, max_visible_rows: 24, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8edf8b61e4..476c5f4721 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -104,6 +104,7 @@ pub(crate) use codex_app_server_client::legacy_core; pub(crate) use worktree_startup::ManagedTuiWorktree; +mod activation_key_guard; mod additional_dirs; mod analytics; mod app; @@ -128,6 +129,7 @@ mod clipboard_paste; mod clock_format; mod collaboration_modes; mod color; +mod config_panel; mod config_update; mod copy_input_guard; pub(crate) mod custom_terminal; diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 9529ba092f..fd06387bfd 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -7,6 +7,7 @@ //! Effective animations also respect the TUI host's launch-time accessibility preference. //! The selected transcript ownership and alternate-screen restrictions survive local reloads. +use crate::config_panel::ConfigToggle; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigTomlLoadResult; use crate::legacy_core::config::TerminalResizeReflowConfig; @@ -28,6 +29,7 @@ pub(crate) struct LocalSettings { pub(crate) transcript_mode: TranscriptMode, pub(crate) history: History, pub(crate) notices: Notice, + pub(crate) custom_notification_filters: bool, pub(crate) codex_home: AbsolutePathBuf, pub(crate) user_config_path: AbsolutePathBuf, } @@ -70,6 +72,7 @@ impl LocalSettings { fullscreen_transcript: config.tui_fullscreen_transcript, mouse_scroll_speed: config.tui_mouse_scroll_speed, copy_on_select: config.tui_copy_on_select, + copy_on_select_clear_selection: config.tui_copy_on_select_clear_selection, right_click_paste: config.tui_right_click_paste, alternate_screen: config.tui_alternate_screen, status_line: config.tui_status_line.clone(), @@ -91,6 +94,7 @@ impl LocalSettings { }, history: config.history.clone(), notices: config.notices.clone(), + custom_notification_filters: custom_notification_filters(&config.config_layer_stack), codex_home: config.codex_home.clone(), user_config_path: config .config_layer_stack @@ -132,6 +136,7 @@ impl LocalSettings { tui, history: config.history.clone().unwrap_or_default(), notices: config.notice.clone().unwrap_or_default(), + custom_notification_filters: custom_notification_filters(&bootstrap.config_layer_stack), user_config_path: bootstrap .config_layer_stack .get_user_config_file() @@ -176,6 +181,15 @@ impl LocalSettings { } } +fn custom_notification_filters(layers: &ConfigLayerStack) -> bool { + layers.effective_user_config().is_some_and(|config| { + config + .get("tui") + .and_then(|tui| tui.get("notifications")) + .is_some_and(toml::Value::is_array) + }) +} + impl LocalSettings { /// Adopt the screen selected before first paint, including command-line restrictions. pub(crate) fn for_tui(config: &Config, tui: &crate::tui::Tui) -> Self { @@ -194,6 +208,7 @@ impl LocalSettings { let mut settings = Self::from(config); settings.transcript_mode = self.transcript_mode; settings.tui.alternate_screen = self.tui.alternate_screen; + ConfigToggle::preserve_restart_required(&mut settings.tui, &self.tui); settings } diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index 17c6a5310c..39c238e1f2 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -20,8 +20,14 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<() .await?; config.tui_fullscreen_transcript = true; config.tui_copy_on_select = CopyOnSelect::Always; + config.tui_copy_on_select_clear_selection = true; config.tui_right_click_paste = RightClickPaste::On; config.tui_alternate_screen = AltScreenMode::Auto; + config.animations = true; + config.tui_auto_recap = true; + config.tui_effects.starfield = true; + config.disable_paste_burst = false; + config.show_tooltips = true; for (alternate_screen, owned, expected_mode, expected_alt) in [ (true, true, TranscriptMode::Owned, AltScreenMode::Auto), @@ -40,15 +46,32 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<() let mut reloaded_config = config.clone(); reloaded_config.tui_fullscreen_transcript = false; reloaded_config.tui_copy_on_select = CopyOnSelect::Never; + reloaded_config.tui_copy_on_select_clear_selection = false; reloaded_config.tui_right_click_paste = RightClickPaste::Off; reloaded_config.tui_alternate_screen = AltScreenMode::Never; reloaded_config.tui_theme = Some("nord".into()); - let mut expected = LocalSettings::from(&reloaded_config); - expected.transcript_mode = expected_mode; - expected.tui.alternate_screen = expected_alt; - expected.tui.right_click_paste = RightClickPaste::Off; - assert_eq!(local.reloaded(&reloaded_config), expected); - assert_eq!(LocalSettings::for_tui(&reloaded_config, &tui), expected); + reloaded_config.animations = false; + reloaded_config.tui_auto_recap = false; + reloaded_config.tui_effects.starfield = false; + reloaded_config.disable_paste_burst = true; + reloaded_config.show_tooltips = false; + let mut expected_reload = LocalSettings::from(&reloaded_config); + expected_reload.transcript_mode = expected_mode; + expected_reload.tui.alternate_screen = expected_alt; + expected_reload.tui.animations = local.tui.animations; + expected_reload.tui.auto_recap = local.tui.auto_recap; + expected_reload.tui.effects.starfield = local.tui.effects.starfield; + expected_reload.tui.disable_paste_burst = local.tui.disable_paste_burst; + expected_reload.tui.show_tooltips = local.tui.show_tooltips; + assert_eq!(local.reloaded(&reloaded_config), expected_reload); + + let mut expected_launch = LocalSettings::from(&reloaded_config); + expected_launch.transcript_mode = expected_mode; + expected_launch.tui.alternate_screen = expected_alt; + assert_eq!( + LocalSettings::for_tui(&reloaded_config, &tui), + expected_launch + ); tui.set_owned_screen(/*owned*/ false)?; } Ok(()) @@ -107,6 +130,7 @@ auto_recap = false fullscreen_transcript = true mouse_scroll_speed = 0.5 copy_on_select = "never" +copy_on_select_clear_selection = true right_click_paste = "off" vim_mode_default = true terminal_resize_reflow_max_rows = 0 @@ -293,6 +317,10 @@ async fn copy_on_select_respects_terminal_defaults_and_config_overrides() -> any #[tokio::test] async fn local_writes_preserve_selected_user_file_and_home_destinations() -> anyhow::Result<()> { let home = tempfile::tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "[tui]\nnotifications = [\"agent-turn-complete\"]\n", + )?; let selected = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?; std::fs::write(&selected, "[tui]\ntheme = \"dracula\"\n")?; let overrides = LoaderOverrides { @@ -308,6 +336,7 @@ async fn local_writes_preserve_selected_user_file_and_home_destinations() -> any .await?; let local = LocalSettings::from(&config); assert_eq!(local.user_config_path, selected); + assert!(local.custom_notification_filters); ConfigEditsBuilder::for_config_path(local.user_config_path.as_path()) .with_edits([crate::legacy_core::config::edit::syntax_theme_edit("nord")]) .apply() diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 685fff820e..b20061fc63 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -61,6 +61,13 @@ impl Overlay { Self::Transcript(overlay) } + pub(crate) fn with_copy_on_select_clear_selection(mut self, enabled: bool) -> Self { + if let Self::Transcript(overlay) = &mut self { + overlay.view.copy_on_select_clear_selection = enabled; + } + self + } + pub(crate) fn new_static_with_lines( lines: Vec>, title: String, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 95ba485746..7424757fe3 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -340,6 +340,7 @@ struct SessionPickerViewPersistence { struct SessionPickerRunOptions { use_theme_colors: bool, copy_on_select: bool, + copy_on_select_clear_selection: bool, mouse_scroll_speed: f64, show_all: bool, filter_cwd: Option, @@ -452,6 +453,7 @@ async fn run_resume_picker_with_launch_context( let options = SessionPickerRunOptions { use_theme_colors: local_settings.tui.status_line_use_colors, copy_on_select: local_settings.copy_on_select(&codex_terminal_detection::terminal_info()), + copy_on_select_clear_selection: local_settings.tui.copy_on_select_clear_selection, mouse_scroll_speed: local_settings.tui.mouse_scroll_speed.unwrap_or(1.0), show_all, filter_cwd: cwd_filter, @@ -513,6 +515,7 @@ pub async fn run_fork_picker_with_app_server( let options = SessionPickerRunOptions { use_theme_colors: local_settings.tui.status_line_use_colors, copy_on_select: local_settings.copy_on_select(&codex_terminal_detection::terminal_info()), + copy_on_select_clear_selection: local_settings.tui.copy_on_select_clear_selection, mouse_scroll_speed: local_settings.tui.mouse_scroll_speed.unwrap_or(1.0), show_all, filter_cwd: cwd_filter, @@ -569,6 +572,7 @@ async fn run_session_picker_with_loader( state.local_filter_cwd = options.local_filter_cwd; state.use_theme_colors = options.use_theme_colors; state.copy_on_select = options.copy_on_select; + state.copy_on_select_clear_selection = options.copy_on_select_clear_selection; state.mouse_scroll_speed = options.mouse_scroll_speed; state.worktrees_enabled = options.worktrees_enabled; state.density = options.initial_density; @@ -848,6 +852,7 @@ struct PickerState { clock_format: ClockFormat, use_theme_colors: bool, copy_on_select: bool, + copy_on_select_clear_selection: bool, mouse_scroll_speed: f64, // Resolve local filesystem membership once per cwd for each page-loading cycle. local_cwd_matches: HashMap, @@ -1050,6 +1055,7 @@ impl PickerState { clock_format: ClockFormat::system(), use_theme_colors: true, copy_on_select: false, + copy_on_select_clear_selection: false, mouse_scroll_speed: 1.0, requester, relative_time_reference: None, @@ -1157,7 +1163,8 @@ impl PickerState { self.keymap.pager.clone(), self.copy_on_select, self.mouse_scroll_speed, - ); + ) + .with_copy_on_select_clear_selection(self.copy_on_select_clear_selection); if let Overlay::Transcript(view) = &mut overlay { view.set_keymap_bindings(&self.keymap); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d9ba053eb5..afb372bb7b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,6 +38,7 @@ pub enum SlashCommand { App, Init, Compact, + Config, Recap, Plan, Voice, @@ -90,6 +91,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Config => "configure Codex preferences", SlashCommand::Feedback => "send logs to maintainers", SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", @@ -300,7 +302,7 @@ impl SlashCommand { SlashCommand::Rollout => true, SlashCommand::TestApproval => true, SlashCommand::Agents | SlashCommand::MultiAgents => true, - SlashCommand::Theme | SlashCommand::Pets => false, + SlashCommand::Config | SlashCommand::Theme | SlashCommand::Pets => false, } } diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs index 562997efee..50dec0b9f0 100644 --- a/codex-rs/tui/src/theme_picker.rs +++ b/codex-rs/tui/src/theme_picker.rs @@ -388,6 +388,7 @@ pub(crate) fn build_theme_picker_params( }) as Box, ); SelectionViewParams { + allow_input_when_disconnected: true, title: Some("Select Syntax Theme".to_string()), subtitle: Some(theme_picker_subtitle( codex_home_owned.as_deref(), diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 9d5b4da271..9a5d1a62e4 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -47,6 +47,26 @@ pub(crate) use input::JumpTarget; pub(crate) use input::ViewAction; pub(crate) use layout::ActivityTranscriptLines; +/// Copy origin plus the deselection policy applied after confirmed delivery. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SelectionCopy { + Explicit, + OnSelect { clear_selection: bool }, +} + +impl SelectionCopy { + fn clear_selection(self) -> bool { + match self { + Self::Explicit => true, + Self::OnSelect { clear_selection } => clear_selection, + } + } + + fn publishes_primary(self) -> bool { + matches!(self, Self::OnSelect { .. }) + } +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum EntryKey { Cell(usize), @@ -89,6 +109,7 @@ pub(crate) struct TranscriptView { pub(crate) mouse_scroll_speed: f64, pending_mouse_scroll: f64, pub(crate) copy_on_select: bool, + pub(crate) copy_on_select_clear_selection: bool, pub(crate) primary_selection: bool, position: Position, follow_control: follow_control::FollowControl, @@ -126,6 +147,7 @@ impl Default for TranscriptView { mouse_scroll_speed: 1.0, pending_mouse_scroll: 0.0, copy_on_select: false, + copy_on_select_clear_selection: false, primary_selection: false, position: Position::Latest, follow_control: follow_control::FollowControl::default(), diff --git a/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs b/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs index 6bfd888e95..69ff193c3b 100644 --- a/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs +++ b/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs @@ -1,4 +1,4 @@ -//! Automatic copies retain completed mouse selections. +//! Automatic copies occur on release and may deselect only after confirmed delivery. use super::*; use crate::clipboard_copy::CopyStatus; @@ -23,11 +23,12 @@ fn mouse(kind: MouseEventKind, column: u16) -> MouseEvent { } #[test] -fn copy_on_select_waits_for_release_and_retains_selection() { +fn copy_on_select_waits_for_release_and_confirmed_delivery() { let cells = vec![cell("selected text")]; - for enabled in [false, true] { + for (enabled, clear_selection) in [(false, false), (true, false), (true, true)] { let mut view = TranscriptView { copy_on_select: enabled, + copy_on_select_clear_selection: clear_selection, ..Default::default() }; render(&mut view, &cells, /*width*/ 20, /*height*/ 1); @@ -41,7 +42,9 @@ fn copy_on_select_waits_for_release_and_retains_selection() { )); } let release = mouse(MouseEventKind::Up(MouseButton::Left), /*column*/ 8); - let copied = match view.handle_mouse(release, &cells) { + let action = view.handle_mouse(release, &cells); + let selection_copy = action.as_ref().map(|action| view.selection_copy(action)); + let copied = match action { Some(ViewAction::CopyOnSelect(text)) => Some(text), Some(ViewAction::Changed) => None, _ => panic!("release must finish the selection"), @@ -51,6 +54,9 @@ fn copy_on_select_waits_for_release_and_retains_selection() { assert!(view.handle_mouse(release, &cells).is_none()); assert!(!view.tick_selection(&cells)); if let Some(copied) = copied { + let selection_copy = selection_copy.expect("copy on select policy"); + assert_eq!(selection_copy, SelectionCopy::OnSelect { clear_selection }); + assert!(selection_copy.publishes_primary()); let buffer = render(&mut view, &cells, /*width*/ 20, /*height*/ 1); let highlight = (0..20) .map(|column| { @@ -64,22 +70,24 @@ fn copy_on_select_waits_for_release_and_retains_selection() { } }) .collect::(); - insta::assert_snapshot!( - format!("{}\n{highlight}", text(&buffer)), - @" - selected text - ^^^^^^^^············ - " - ); + insta::allow_duplicates! { + insta::assert_snapshot!( + format!("{}\n{highlight}", text(&buffer)), + @" + selected text + ^^^^^^^^············ + " + ); + } for result in [ - Ok(CopyStatus::Confirmed), Ok(CopyStatus::Unconfirmed), Err("clipboard unavailable".to_owned()), + Ok(CopyStatus::Confirmed), ] { view.copy_selected_text_with( &cells, &copied, - /*clear_selection*/ false, + selection_copy.clear_selection(), |_, _format| Ok(CopyStatus::Pending(1)), ) .unwrap(); @@ -87,7 +95,11 @@ fn copy_on_select_waits_for_release_and_retains_selection() { view.finish_copy(&cells, &(1, result.clone()), /*current*/ true), Some(false) ); - assert_eq!(view.selected_text(&cells).as_deref(), Some(copied.as_str())); + assert_eq!( + view.selected_text(&cells).as_deref(), + (!clear_selection || result != Ok(CopyStatus::Confirmed)) + .then_some(copied.as_str()) + ); assert_eq!( view.copy_feedback .as_ref() @@ -95,6 +107,28 @@ fn copy_on_select_waits_for_release_and_retains_selection() { Some((result.map_err(|_| ()), copied.chars().count())) ); } + if clear_selection { + let buffer = render(&mut view, &cells, /*width*/ 20, /*height*/ 1); + let highlight = (0..20) + .map(|column| { + if buffer[(column, 0)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + { + '^' + } else { + '·' + } + }) + .collect::(); + insta::assert_snapshot!( + format!("{}\n{highlight}", text(&buffer)), + @" + selected text + ···················· + " + ); + } } } } diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index e6050deec3..bf3a479bb1 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -2,7 +2,8 @@ //! Stationary link clicks open on release; dragging or scrolling keeps the gesture in selection. //! Shift-click extends the existing selection from its original text unit. //! Optional automatic copying happens only when a nonempty mouse selection is released. -//! Automatic copies retain the selection; explicit copies clear it after confirmed delivery. +//! Automatic copies optionally clear the selection; explicit copies always clear it after +//! confirmed delivery. //! Wheel input accumulates fractional rows and discards the remainder when direction reverses. use crate::key_hint::KeyBindingListExt; @@ -88,6 +89,20 @@ impl JumpTarget { } impl TranscriptView { + /// Automatic copies may deselect but still publish PRIMARY, so retain their source. + pub(crate) fn selection_copy(&self, action: &ViewAction) -> SelectionCopy { + match action { + ViewAction::CopyOnSelect(_) => SelectionCopy::OnSelect { + clear_selection: self.copy_on_select_clear_selection, + }, + ViewAction::Changed + | ViewAction::PrimarySelection(_) + | ViewAction::Copy(_) + | ViewAction::CopyAndFollow(_) + | ViewAction::OpenLink(_) => SelectionCopy::Explicit, + } + } + /// Resolve links in the last rendered transcript, sharing geometry with click activation. pub(crate) fn link_at(&self, column: u16, row: u16) -> Option { if !self.area.contains(ScreenPosition::new(column, row)) { diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index f62b427fb2..9d3c9d4852 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -291,8 +291,8 @@ impl TranscriptView { } } - /// Track delivery for the current selection. Explicit copies release it on confirmation; - /// automatic copies, failures, and unacknowledged terminal writes retain the revision. + /// Deselect only after confirmed delivery when the current copy policy requests it. + /// Failures and unacknowledged terminal writes retain the selection. pub(crate) fn copy_selected_text_with( &mut self, cells: &[Arc], @@ -396,8 +396,9 @@ impl TranscriptView { } ViewAction::Changed | ViewAction::OpenLink(_) => return None, }; - let clear_selection = !matches!(action, ViewAction::CopyOnSelect(_)); - let publish_primary = !clear_selection && self.primary_selection; + let selection_copy = self.selection_copy(action); + let clear_selection = selection_copy.clear_selection(); + let publish_primary = selection_copy.publishes_primary() && self.primary_selection; let mut primary_owner = None; let result = self.copy_selected_text_with(cells, text, clear_selection, |rich, rich_format| { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 434ac1140b..f17c2d0462 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -71,6 +71,7 @@ mod input_boundary; mod job_control; mod keyboard_modes; mod link_pointer; +mod notification_preview; #[cfg(test)] #[path = "tui/owned_screen_tests.rs"] mod owned_screen_tests; @@ -845,6 +846,10 @@ impl Tui { self.notification_condition = condition; } + pub(crate) fn set_notification_condition(&mut self, condition: NotificationCondition) { + self.notification_condition = condition; + } + pub(crate) fn is_terminal_focused(&self) -> bool { self.terminal_focused.load(Ordering::Relaxed) } @@ -904,12 +909,12 @@ impl Tui { Ok(()) } - /// Discard buffered typeahead before a startup screen that can confirm an action. + /// Discard buffered typeahead before an interactive screen that can confirm an action. /// - /// Startup probes can leave parsed key events in crossterm's queue, while later bootstrap - /// work can leave additional bytes in the terminal input buffer. Neither should activate an - /// update, trust, or migration prompt before the user has seen it. Pause the event stream, - /// drain all input through crossterm so incomplete bracketed paste remains safely framed. + /// Earlier work can leave parsed key events in crossterm's queue and additional bytes in the + /// terminal input buffer. Neither should activate the next screen before the user has seen it. + /// Pause the event stream and drain all input through crossterm so incomplete bracketed paste + /// remains safely framed. pub(crate) fn discard_pending_input_before_interactive_screen(&mut self) -> Result<()> { self.pause_events(); let drain_result = discard_pending_terminal_input(); diff --git a/codex-rs/tui/src/tui/notification_preview.rs b/codex-rs/tui/src/tui/notification_preview.rs new file mode 100644 index 0000000000..9ed9494c22 --- /dev/null +++ b/codex-rs/tui/src/tui/notification_preview.rs @@ -0,0 +1,18 @@ +//! Notification previews bypass focus filtering without reviving a failed backend. + +use super::Tui; +use codex_config::types::NotificationCondition; + +impl Tui { + pub(crate) fn notify_test(&mut self, message: &str) -> bool { + let condition = self.notification_condition; + self.notification_condition = NotificationCondition::Always; + let sent = self.notify(message); + self.notification_condition = condition; + sent + } +} + +#[cfg(test)] +#[path = "notification_preview_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/notification_preview_tests.rs b/codex-rs/tui/src/tui/notification_preview_tests.rs new file mode 100644 index 0000000000..5167b4f22e --- /dev/null +++ b/codex-rs/tui/src/tui/notification_preview_tests.rs @@ -0,0 +1,15 @@ +//! Focus policy and backend state survive a notification preview. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn notification_preview_preserves_disabled_backend_and_focus_policy() { + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + tui.notification_backend = None; + tui.set_notification_condition(NotificationCondition::Unfocused); + + assert!(!tui.notify_test("preview")); + assert_eq!(tui.notification_condition, NotificationCondition::Unfocused); + assert!(tui.notification_backend.is_none()); +}