mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
+13
@@ -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
|
||||
+12
@@ -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
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
+24
@@ -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
|
||||
+15
@@ -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
|
||||
+13
@@ -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
|
||||
+21
@@ -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
|
||||
+12
@@ -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
|
||||
+23
@@ -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
|
||||
+23
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
····················
|
||||
"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user