Add a searchable /config preferences panel to the TUI (#52990)

## What changed

- Group preferences into Appearance, Notifications, Input, Sessions, and Text selection tabs, with search and links to existing theme, status line, terminal title, and keyboard editors.
- Persist preference changes to the selected local configuration file, apply supported changes immediately, and show save errors, configuration overrides, and restart requirements in the panel.
- Add a delayed notification test and controls for notification timing while preserving custom event filters.
- Add `tui.copy_on_select_clear_selection`, disabled by default, to clear transcript selection highlights only after confirmed automatic clipboard delivery.
- Keep local configuration editors usable while disconnected and prevent held activation keys or buffered input from triggering actions across editor and reconnect transitions.

## Testing

Add regression tests and rendering snapshots for panel navigation, search, dependency states, editor handoffs, offline persistence, notification timers, custom filter preservation, and confirmed-copy deselection.

GitOrigin-RevId: a16d218046060fcb6e61705a803fcd9297266e6e
This commit is contained in:
Felipe Coury
2026-10-10 22:28:01 +00:00
committed by copyberry
parent de8fab6d7a
commit cc7ba33601
95 changed files with 4114 additions and 187 deletions
+5
View File
@@ -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.
+5
View File
@@ -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"
+2
View File
@@ -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,
+18
View File
@@ -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."<path>"]`,
/// 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());
+22 -14
View File
@@ -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]
+7
View File
@@ -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()
@@ -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,
+71
View File
@@ -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;
@@ -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());
}
+39 -9
View File
@@ -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<tokio::sync::Mutex<()>>,
config_notification_test_generation: u64,
config_notification_test_pending: Option<u64>,
model_catalog: Arc<ModelCatalog>,
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<KeyBinding>,
/// The foreground loop owns stream pacing; stopped animations have no timer.
pub(crate) commit_animation: Option<tokio::time::Interval>,
@@ -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,
+169
View File
@@ -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;
+208
View File
@@ -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(())
}
+19 -2
View File
@@ -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]
+65 -12
View File
@@ -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::<Vec<_>>();
@@ -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}"));
}
}
}
+22 -3
View File
@@ -69,9 +69,20 @@ impl App {
tui: &mut tui::Tui,
key_event: KeyEvent,
) -> Option<KeyEvent> {
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);
}
};
}
+2
View File
@@ -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();
+6 -14
View File
@@ -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<Option<crate::pets::AmbientPet>, 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();
}
}
}
+31 -6
View File
@@ -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(())
}
}
+15
View File
@@ -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,
+39 -13
View File
@@ -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<bool, &'static str> {
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,
};
@@ -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]
@@ -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);
+7 -3
View File
@@ -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());
+3
View File
@@ -22,6 +22,8 @@ pub(crate) async fn make_test_app() -> Box<App> {
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<App> {
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)),
+11
View File
@@ -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<App> {
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<App> {
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)),
@@ -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;
+103
View File
@@ -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<()> {
@@ -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)
+15 -7
View File
@@ -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()
{
+16
View File
@@ -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<Option<crate::pets::AmbientPet>, 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,
@@ -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<KeyBinding>,
) {
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
@@ -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
@@ -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
@@ -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 [
@@ -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);
}
}
}
}
}
@@ -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<String>,
pub subtitle: Option<String>,
@@ -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<KeyBinding>,
pub search_mode: SearchMode,
pub search_placeholder: Option<String>,
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<usize>,
/// Restore a filter together with its actual selected item after a source refresh.
pub initial_search_query: Option<String>,
/// 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<Line<'static>>,
footer_hint: Option<Line<'static>>,
@@ -315,6 +333,8 @@ pub(crate) struct ListSelectionView {
pub(super) app_event_tx: AppEventSender,
is_searchable: bool,
search_toggle_key: Option<KeyBinding>,
search_mode: SearchMode,
search_active: bool,
search_query: String,
search_placeholder: Option<String>,
col_width_mode: ColumnWidthMode,
@@ -323,7 +343,8 @@ pub(crate) struct ListSelectionView {
name_column_width: Option<usize>,
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
rendered_item_count: std::cell::Cell<usize>,
rendered_item_count: Cell<usize>,
search_cursor: Cell<Option<(u16, u16)>>,
header: Box<dyn Renderable>,
header_view_all_hint: Option<ShortcutHint>,
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::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
+137 -2
View File
@@ -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<warnings_view::WarningsView>,
/// 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<crate::activation_key_guard::ActivationKeyGuard>,
pub(crate) questions: Option<Box<AsyncQuestions>>,
delayed_approval_requests: VecDeque<DelayedApprovalRequest>,
last_composer_activity_at: Option<Instant>,
@@ -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<KeyBinding>,
) -> 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::<AppEvent>();
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<bool>,
release_seen: Cell<bool>,
interrupted: Cell<bool>,
}
struct TrackingView(Rc<CoveredEvents>);
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::<AppEvent>();
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>,
@@ -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);
}
@@ -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);
}
+5 -1
View File
@@ -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<String>,
@@ -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<crate::pets::AmbientPet>,
configured_pet_load_generation: ConfiguredPetLoadGeneration,
pet_picker_preview_state: crate::pets::PetPickerPreviewState,
pet_picker_preview_pet: Option<crate::pets::AmbientPet>,
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<Notification>,
pub(crate) pending_notification: Option<Notification>,
/// 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<Instant>,
+112
View File
@@ -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<String>) -> 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));
}
}
+4 -1
View File
@@ -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,
@@ -30,6 +30,7 @@ impl ChatWidget {
SlashCommand::Model
| SlashCommand::Keymap
| SlashCommand::Memories
| SlashCommand::Config
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme,
+21 -3
View File
@@ -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<crate::key_hint::KeyBinding>,
) -> 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
@@ -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();
}
+1 -1
View File
@@ -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<PathBuf> },
+55 -3
View File
@@ -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<String>) {
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<Option<crate::pets::AmbientPet>, 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<String>,
ambient_pet: Option<crate::pets::AmbientPet>,
) {
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();
+66 -1
View File
@@ -11,13 +11,14 @@ fn pet_load_without_runtime_sends_completion_event() {
async { Ok::<Option<crate::pets::AmbientPet>, 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) =
+29 -2
View File
@@ -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<crate::key_hint::KeyBinding>,
) {
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) {
+1
View File
@@ -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<String>) {
self.config_panel_tui.theme.clone_from(&theme);
self.local_settings.tui.theme = theme;
}
@@ -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,
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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();
}
@@ -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());
}
@@ -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<AppEvent>) {
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::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.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
));
}
+186
View File
@@ -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<usize> {
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<crate::key_hint::KeyBinding>,
) {
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()
}
}
}
+492
View File
@@ -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<String>,
}
#[derive(Default)]
struct PickerState {
tab: Option<String>,
selected: Option<usize>,
query: Option<String>,
notice: Option<String>,
}
#[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<bool, &'static str>,
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<Mutex<ConfigEditorActivation>>,
}
#[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<Instant> {
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<ConfigSetting> {
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<ConfigSetting>,
picker: ListSelectionView,
saving: bool,
editor_opening: Option<ConfigEditorHandoff>,
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,
}
}
}
+405
View File
@@ -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<Line<'static>>);
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<dyn Renderable> {
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<KeyBinding>) -> 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::<Vec<_>>()
.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::<Vec<_>>();
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)
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+173
View File
@@ -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<ResumeCwdMode>),
}
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,
}
}
}
+15
View File
@@ -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<Span<'static>> {
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);
};
+7
View File
@@ -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<ShortcutHint> {
let action_id = keymap_action_id(KeymapContext::List.config_name(), action.config_name())?;
self.chord_hints
+22 -16
View File
@@ -394,23 +394,29 @@ Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings."
action: KeymapActionId,
bindings: &[KeyBinding],
) -> Option<crate::key_hint::ShortcutHint> {
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)
+15
View File
@@ -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]
+4
View File
@@ -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
+4
View File
@@ -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;
+1
View File
@@ -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,
+2
View File
@@ -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;
+15
View File
@@ -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
}
+35 -6
View File
@@ -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()
+7
View File
@@ -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<Line<'static>>,
title: String,
+8 -1
View File
@@ -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<PathBuf>,
@@ -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<PathBuf, bool>,
@@ -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);
}
+3 -1
View File
@@ -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,
}
}
+1
View File
@@ -388,6 +388,7 @@ pub(crate) fn build_theme_picker_params(
}) as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>,
);
SelectionViewParams {
allow_input_when_disconnected: true,
title: Some("Select Syntax Theme".to_string()),
subtitle: Some(theme_picker_subtitle(
codex_home_owned.as_deref(),
+22
View File
@@ -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(),
@@ -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::<String>();
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::<String>();
insta::assert_snapshot!(
format!("{}\n{highlight}", text(&buffer)),
@"
selected text
····················
"
);
}
}
}
}
+16 -1
View File
@@ -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<String> {
if !self.area.contains(ScreenPosition::new(column, row)) {
@@ -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<dyn HistoryCell>],
@@ -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| {
+10 -5
View File
@@ -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();
@@ -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;
@@ -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());
}