diff --git a/codex-rs/tui/src/app/config_panel.rs b/codex-rs/tui/src/app/config_panel.rs index f03ac38931..8d0d85c53b 100644 --- a/codex-rs/tui/src/app/config_panel.rs +++ b/codex-rs/tui/src/app/config_panel.rs @@ -45,14 +45,9 @@ impl App { } }; - 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); - } + preference.adopt(&mut self.local_settings.tui, &resolved.tui); + self.chat_widget + .apply_config_preference(preference, &resolved.tui); if matches!( preference, ConfigPreference::Toggle { @@ -67,7 +62,7 @@ impl App { 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(); + preference.adopt(&mut self.chat_widget.config_panel_tui, &resolved.tui); if matches!( preference, ConfigPreference::Toggle { @@ -95,9 +90,7 @@ impl App { 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" + Some("Saved · overridden by config or system setting") } else if matches!( preference, ConfigPreference::Toggle { @@ -105,12 +98,12 @@ impl App { .. } ) { - "Saved · applies to new composers" + Some("Saved · applies to new composers") } else { - "Saved" + None }; self.chat_widget - .refresh_config_panel(/*notice*/ Some(notice.to_string())); + .refresh_config_panel(notice.map(str::to_string)); tui.frame_requester().schedule_frame(); } } @@ -119,7 +112,7 @@ 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(), + "Test already scheduled · switch apps".into(), )); return; } @@ -128,9 +121,9 @@ impl App { .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(), - )); + self.chat_widget.refresh_config_panel( + /*notice*/ Some("Test scheduled in 5s · switch apps".into()), + ); let tx = self.app_event_tx.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(/*secs*/ 5)).await; @@ -144,14 +137,14 @@ impl App { } 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" + "Could not send test · check 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" + "Test sent · Ghostty hides banners while focused" } else { - "Test sent to terminal · delivery depends on terminal and system settings" + "Test sent · check terminal/system settings" }; if !self .chat_widget diff --git a/codex-rs/tui/src/app/config_panel_tests.rs b/codex-rs/tui/src/app/config_panel_tests.rs index 0df17e7415..6959d4d215 100644 --- a/codex-rs/tui/src/app/config_panel_tests.rs +++ b/codex-rs/tui/src/app/config_panel_tests.rs @@ -5,12 +5,15 @@ 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::chatwidget::tests::render_bottom_popup; +use crate::config_panel::ConfigEditor; +use crate::config_panel::ConfigEditorHandoff; 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<()> { +async fn config_toggle_save_uses_host_config_and_applies_live_while_offline() -> Result<()> { let root = tempfile::tempdir()?; let home = root.path().join("home"); let local_cwd = root.path().join("local"); @@ -80,18 +83,36 @@ async fn config_toggle_save_uses_host_config_while_offline() -> Result<()> { )) .await?; + app.chat_widget.handle_key_event(KeyCode::Char('a').into()); + assert!(app.chat_widget.is_in_paste_burst()); + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::PasteDetection, + 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, + app.local_settings.tui.disable_paste_burst, + app.chat_widget.local_settings.tui.disable_paste_burst, ), - (true, true, false), + (false, false, false, Some(true), Some(true)), ); + assert!(!app.chat_widget.is_in_paste_burst()); + app.chat_widget.handle_key_event(KeyCode::Char('b').into()); + assert!(!app.chat_widget.is_in_paste_burst()); 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!(saved["tui"]["disable_paste_burst"].as_bool(), Some(true)); assert_eq!(app.launch_cwd, local_cwd.to_path_buf()); assert_eq!( (&app.config.cwd, &app.chat_widget.config_ref().cwd), @@ -102,6 +123,36 @@ async fn config_toggle_save_uses_host_config_while_offline() -> Result<()> { Ok(()) } +#[tokio::test] +async fn effects_editor_refreshes_after_animation_save() -> 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()?; + app.local_settings.tui.animations = false; + app.chat_widget.local_settings.tui.animations = false; + app.chat_widget.config_panel_tui.animations = false; + let handoff = ConfigEditorHandoff::new( + ConfigEditor::Effects, + crate::key_hint::plain(KeyCode::Enter), + ); + handoff.mark_activation_inactive(); + app.chat_widget.open_config_editor( + handoff, + crate::config_panel::ResumeCurrentAvailability::Available, + ); + + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let AppEvent::SaveConfigPreference(preference) = rx.try_recv()? else { + panic!("expected animation save"); + }; + app.save_config_preference(&mut tui, preference).await; + + insta::assert_snapshot!( + "effects_picker_after_animation_save", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + 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; @@ -146,7 +197,7 @@ async fn notification_test_delay_rejects_cancelled_and_rescheduled_timers() -> R assert!( cell.display_lines(/*width*/ 120) .iter() - .any(|line| line.to_string().starts_with("• Test sent to terminal · ")) + .any(|line| line.to_string().starts_with("• Test sent · ")) ); app.schedule_config_notification_test(); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 23e8ece57f..9d6c3dcc61 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3246,7 +3246,7 @@ 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())); + self.chat_widget.refresh_config_panel(/*notice*/ None); } Err(err) => { let error = format_config_error(&err); @@ -3288,7 +3288,7 @@ 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())); + self.chat_widget.refresh_config_panel(/*notice*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); @@ -3357,7 +3357,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.chat_widget.refresh_config_panel(/*notice*/ None); self.refresh_status_line(); tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/app/right_click_paste.rs b/codex-rs/tui/src/app/right_click_paste.rs index 684390614f..632e134a43 100644 --- a/codex-rs/tui/src/app/right_click_paste.rs +++ b/codex-rs/tui/src/app/right_click_paste.rs @@ -54,17 +54,17 @@ impl PasteEnvironment { return Ok(false); } if !transcript_mode.is_owned() { - return Err("Unavailable in inline mode. Use your terminal's paste command."); + 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."); + 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."); + 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.", + "Auto cannot identify this WSL terminal; use On or your terminal's paste command", ); } match mode { diff --git a/codex-rs/tui/src/app/right_click_paste_tests.rs b/codex-rs/tui/src/app/right_click_paste_tests.rs index ca36014ac8..b24cad29ac 100644 --- a/codex-rs/tui/src/app/right_click_paste_tests.rs +++ b/codex-rs/tui/src/app/right_click_paste_tests.rs @@ -7,12 +7,12 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() { use VscodeDetection::Other; use VscodeDetection::Unknown; use VscodeDetection::VsCode; - 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 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."; + "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."; + "Unavailable in inline mode; use your terminal's paste command"; let on = if cfg!(target_os = "android") { Err("Unavailable on Android") } else { diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__config_panel__tests__effects_picker_after_animation_save.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__config_panel__tests__effects_picker_after_animation_save.snap new file mode 100644 index 0000000000..9339b0b97a --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__config_panel__tests__effects_picker_after_animation_save.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/config_panel_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Effects + Configure visual effects + + +› 1. [x] Animations Animate the welcome screen, working indicator, and + transitions + 2. [x] Composer stars Show stars briefly for new Astra tasks + + enter toggle · esc back diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index afd1686038..7c2d6a855a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -33,6 +33,7 @@ use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use crate::wrapping::word_wrap_lines; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -48,6 +49,7 @@ use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height_with_col_width_mode; use super::selection_popup_common::render_rows_single_line_with_col_width_mode; use super::selection_popup_common::render_rows_with_col_width_mode; +use super::selection_popup_common::should_show_selected_description_below; pub(crate) use super::selection_row_layout::SelectionDescriptionLayout; use super::selection_tabs::SelectionTab; use super::selection_tabs::render_tab_bar; @@ -165,12 +167,18 @@ pub(crate) struct SelectionItem { pub toggle: Option, pub toggle_placeholder: Option<&'static str>, pub display_shortcut: Option, + /// Required row metadata retained when the secondary description hides. + pub category_tag: Option, pub description: Option, pub selected_description: Option, pub is_current: bool, pub is_disabled: bool, pub actions: Vec, pub secondary_action: Option, + /// Footer hint shown while this row is selected. + pub footer_hint: Option>, + /// Footer hint shown instead while on-demand search is active. + pub search_footer_hint: Option>, pub dismiss_on_select: bool, /// Require an explicit accept key after a direct shortcut highlights this sensitive item. pub require_explicit_confirmation: bool, @@ -208,6 +216,8 @@ pub(crate) struct SelectionViewParams { pub footer_hint: Option>, pub tab_footer_hints: Vec<(String, Line<'static>)>, pub items: Vec, + /// Optional flat item list used while a tabbed picker has a non-empty search query. + pub search_items: Vec, pub tabs: Vec, pub initial_tab_id: Option, pub is_searchable: bool, @@ -274,6 +284,7 @@ impl Default for SelectionViewParams { footer_hint: None, tab_footer_hints: Vec::new(), items: Vec::new(), + search_items: Vec::new(), tabs: Vec::new(), initial_tab_id: None, is_searchable: false, @@ -325,6 +336,7 @@ pub(crate) struct ListSelectionView { footer_hint: Option>, tab_footer_hints: Vec<(String, Line<'static>)>, items: Vec, + search_items: Vec, tabs: Vec, active_tab_idx: Option, state: ScrollState, @@ -473,6 +485,7 @@ impl ListSelectionView { footer_hint: params.footer_hint, tab_footer_hints: params.tab_footer_hints, items: params.items, + search_items: params.search_items, tabs: params.tabs, active_tab_idx, state: ScrollState::new(), @@ -526,7 +539,23 @@ impl ListSelectionView { self.active_tab_idx.is_some() } + fn search_items_active(&self) -> bool { + !self.search_query.is_empty() && !self.search_items.is_empty() + } + + fn edit_search_query(&mut self, edit: impl FnOnce(&mut String)) { + let search_items_were_active = self.search_items_active(); + edit(&mut self.search_query); + if search_items_were_active != self.search_items_active() { + self.state.reset(); + } + self.apply_filter(); + } + fn active_items(&self) -> &[SelectionItem] { + if self.search_items_active() { + return self.search_items.as_slice(); + } self.active_tab_idx .and_then(|idx| self.tabs.get(idx)) .map(|tab| tab.items.as_slice()) @@ -534,6 +563,9 @@ impl ListSelectionView { } fn active_items_mut(&mut self) -> &mut [SelectionItem] { + if self.search_items_active() { + return self.search_items.as_mut_slice(); + } if let Some(idx) = self.active_tab_idx && let Some(tab) = self.tabs.get_mut(idx) { @@ -565,12 +597,21 @@ impl ListSelectionView { } fn active_footer_hint(&self) -> Option<&Line<'static>> { - if let Some(action) = self + if let Some(item) = self .selected_actual_idx() .and_then(|idx| self.active_items().get(idx)) - .and_then(|item| item.secondary_action.as_ref()) { - return Some(&action.footer_hint); + if self.on_demand_search_active() + && let Some(hint) = item.search_footer_hint.as_ref() + { + return Some(hint); + } + if let Some(hint) = item.footer_hint.as_ref() { + return Some(hint); + } + if let Some(action) = item.secondary_action.as_ref() { + return Some(&action.footer_hint); + } } self.active_tab_id() .and_then(|active_tab_id| { @@ -735,7 +776,7 @@ impl ListSelectionView { let wrap_indent = (description.is_none() && item.disabled_reason.is_none()) .then_some(wrap_prefix_width); GenericDisplayRow { - category_tag: None, + category_tag: item.category_tag.clone(), selection_style: row_selection_style, name: name_with_marker, name_prefix_spans, @@ -766,7 +807,9 @@ impl ListSelectionView { (active_idx + 1) % len }; self.active_tab_idx = Some(next_idx); - self.search_query.clear(); + if self.search_items.is_empty() { + self.search_query.clear(); + } self.rendered_item_count.set(/*val*/ 0); self.state.reset(); self.apply_filter(); @@ -961,16 +1004,22 @@ impl ListSelectionView { fn stop_on_demand_search(&mut self) { self.search_active = false; - self.search_query.clear(); - self.apply_filter(); + self.edit_search_query(String::clear); } pub(crate) fn set_search_query(&mut self, query: String) { let selected_actual_idx = self.selected_actual_idx(); + let changes_item_source = + self.search_items_active() != (!query.is_empty() && !self.search_items.is_empty()); self.search_active = true; self.search_query = query; - self.state.selected_idx = None; - self.initial_selected_idx = selected_actual_idx; + if changes_item_source { + self.state.reset(); + self.initial_selected_idx = None; + } else { + self.state.selected_idx = None; + self.initial_selected_idx = selected_actual_idx; + } self.apply_filter(); } @@ -993,6 +1042,53 @@ impl ListSelectionView { .iter() .map(|hint| wrap_styled_line(hint, width).len()), ) + .chain( + self.items + .iter() + .chain(self.search_items.iter()) + .chain(self.tabs.iter().flat_map(|tab| tab.items.iter())) + .flat_map(|item| { + [ + item.footer_hint.as_ref().or_else(|| { + item.secondary_action + .as_ref() + .map(|action| &action.footer_hint) + }), + item.search_footer_hint.as_ref(), + ] + .into_iter() + .flatten() + }) + .map(|hint| wrap_styled_line(hint, width).len()), + ) + .max() + .unwrap_or(lines.len()); + lines.resize(height.max(lines.len()), Line::default()); + } + lines + } + + fn footer_note_lines(&self, width: u16, show_selected_description: bool) -> Vec> { + let wrap = |line| word_wrap_lines([line], usize::from(width.max(1))); + let note = if show_selected_description { + self.selected_actual_idx() + .and_then(|idx| self.active_items().get(idx)) + .and_then(|item| item.description.as_ref()) + .map(|description| Line::from(description.clone()).dim()) + } else { + None + } + .or_else(|| self.footer_note.clone()); + let mut lines = note.map(&wrap).unwrap_or_default(); + if self.reserve_result_rows && show_selected_description { + let height = self + .items + .iter() + .chain(self.search_items.iter()) + .chain(self.tabs.iter().flat_map(|tab| tab.items.iter())) + .filter_map(|item| item.description.as_deref()) + .map(|description| wrap(Line::from(description).dim()).len()) + .chain(self.footer_note.iter().map(|note| wrap(note.clone()).len())) .max() .unwrap_or(lines.len()); lines.resize(height.max(lines.len()), Line::default()); @@ -1212,8 +1308,9 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Backspace, .. } if search_field_visible => { - self.search_query.pop(); - self.apply_filter(); + self.edit_search_query(|query| { + query.pop(); + }); } KeyEvent { code: KeyCode::Char(' '), @@ -1274,8 +1371,7 @@ impl BottomPaneView for ListSelectionView { && !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { - self.search_query.push(c); - self.apply_filter(); + self.edit_search_query(|query| query.push(c)); } KeyEvent { code: KeyCode::Char(c), @@ -1314,8 +1410,7 @@ impl BottomPaneView for ListSelectionView { let Some(pasted) = normalize_pasted_search_query(&pasted) else { return false; }; - self.search_query.push_str(&pasted); - self.apply_filter(); + self.edit_search_query(|query| query.push_str(&pasted)); true } @@ -1437,11 +1532,17 @@ impl Renderable for ListSelectionView { } } - if let Some(note) = &self.footer_note { - let note_width = width.saturating_sub(2); - let note_lines = wrap_styled_line(note, note_width); - height = height.saturating_add(note_lines.len() as u16); - } + let show_selected_description = should_show_selected_description_below( + &rows, + &self.state, + self.max_visible_rows, + effective_rows_width, + column_width, + ); + height = height.saturating_add( + self.footer_note_lines(width.saturating_sub(/*rhs*/ 2), show_selected_description) + .len() as u16, + ); height = height.saturating_add( self.footer_hint_lines(width.saturating_sub(/*rhs*/ 2)) .len() as u16, @@ -1456,16 +1557,29 @@ impl Renderable for ListSelectionView { return; } let search_field_visible = self.search_field_visible(); - + let inner_width = popup_content_width(area.width); + let side_w = self.side_layout_width(inner_width); + let effective_rows_width = if let Some(sw) = side_w { + area.width + .saturating_sub(MENU_SURFACE_HORIZONTAL_INSET / 2 + SIDE_CONTENT_GAP + sw) + } else { + area.width + }; + let rows = self.build_rows(); + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) + .with_description_layout(self.description_layout); + let show_selected_description = should_show_selected_description_below( + &rows, + &self.state, + self.max_visible_rows, + effective_rows_width, + column_width, + ); let note_width = area.width.saturating_sub(2); - let note_lines = self - .footer_note - .as_ref() - .map(|note| wrap_styled_line(note, note_width)); - let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); + let note_lines = self.footer_note_lines(note_width, show_selected_description); + let note_height = note_lines.len() as u16; let hint_lines = self.footer_hint_lines(note_width); let hint_height = hint_lines.len() as u16; - let inner_width = popup_content_width(area.width); let header_height = self.header_height(inner_width); let tab_height = tab_bar_height(&self.tabs, inner_width); let mut footer_rows = note_height + hint_height; @@ -1502,21 +1616,7 @@ impl Renderable for ListSelectionView { content_area }; - let side_w = self.side_layout_width(inner_width); - - // When side-by-side is active, shrink the list to make room. - let effective_rows_width = if let Some(sw) = side_w { - outer_content_area - .width - .saturating_sub(MENU_SURFACE_HORIZONTAL_INSET / 2 + SIDE_CONTENT_GAP + sw) - } else { - outer_content_area.width - }; - let header = self.active_header(inner_width); - let rows = self.build_rows(); - let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) - .with_description_layout(self.description_layout); let rows_height = match self.row_display { SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( &rows, @@ -1711,14 +1811,14 @@ impl Renderable for ListSelectionView { ]) .areas(footer_area); - if let Some(lines) = note_lines { + if !note_lines.is_empty() { let note_area = Rect { x: note_area.x + 2, y: note_area.y, width: note_area.width.saturating_sub(2), height: note_area.height, }; - for (idx, line) in lines.iter().enumerate() { + for (idx, line) in note_lines.iter().enumerate() { if idx as u16 >= note_area.height { break; } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 996ff53548..221a281015 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -426,6 +426,15 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_motion_preferences(&mut self, tui: &codex_config::types::Tui) { + self.animations_enabled = tui.animations; + self.effects = tui.effects; + if let Some(status) = self.status.as_mut() { + status.set_motion_preferences(tui); + } + self.request_redraw(); + } + /// Mirrors the effective reasoning effort into the composer so its next /// visible frame can play a one-shot Max/Ultra effect. pub(crate) fn set_active_reasoning_effort(&mut self, effort: Option<&ReasoningEffort>) { diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index fa2c9f2196..0059d56124 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -176,7 +176,7 @@ fn compute_desc_col( .min(max_auto_desc_col); if column_width .description_layout - .should_hide(content_width, desc_col) + .should_hide_column(content_width, desc_col) { 0 } else { @@ -184,6 +184,24 @@ fn compute_desc_col( } } +pub(super) fn should_show_selected_description_below( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, + column_width: ColumnWidthConfig, +) -> bool { + if rows_all.is_empty() { + return false; + } + let visible_items = max_results.min(rows_all.len()); + let start_idx = compute_item_window_start(rows_all, state, visible_items); + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, width, column_width); + column_width + .description_layout + .should_show_selected_below(width, desc_col) +} + /// Determine how many spaces to indent wrapped lines for a row. fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { let max_indent = max_width.saturating_sub(1) as usize; diff --git a/codex-rs/tui/src/bottom_pane/selection_row_layout.rs b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs index 50678b8e39..573f015b65 100644 --- a/codex-rs/tui/src/bottom_pane/selection_row_layout.rs +++ b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs @@ -15,19 +15,33 @@ pub(crate) enum SelectionDescriptionLayout { HideWhenNarrow { min_description_width: u16, }, + SelectedBelowWhenNarrow { + min_description_width: u16, + }, } impl SelectionDescriptionLayout { - pub(super) fn should_hide(self, width: u16, desc_col: usize) -> bool { - let Self::HideWhenNarrow { - min_description_width, - } = self - else { - return false; + fn is_narrow(self, width: u16, desc_col: usize) -> bool { + let min_description_width = match self { + Self::Columns => return false, + Self::HideWhenNarrow { + min_description_width, + } + | Self::SelectedBelowWhenNarrow { + min_description_width, + } => min_description_width, }; let desc_col = desc_col.min(width as usize) as u16; width.saturating_sub(desc_col) < min_description_width } + + pub(super) fn should_hide_column(self, width: u16, desc_col: usize) -> bool { + matches!(self, Self::HideWhenNarrow { .. }) && self.is_narrow(width, desc_col) + } + + pub(super) fn should_show_selected_below(self, width: u16, desc_col: usize) -> bool { + matches!(self, Self::SelectedBelowWhenNarrow { .. }) && self.is_narrow(width, desc_col) + } } fn combined_description( @@ -41,6 +55,7 @@ fn combined_description( if matches!( description_layout, SelectionDescriptionLayout::HideWhenNarrow { .. } + | SelectionDescriptionLayout::SelectedBelowWhenNarrow { .. } ) => { Some(reason.clone()) @@ -104,9 +119,10 @@ pub(super) fn build_full_line( width: u16, description_layout: SelectionDescriptionLayout, ) -> Line<'static> { - let description = (desc_col > 0) - .then(|| combined_description(row, description_layout)) - .flatten(); + let description = (desc_col > 0 + && !description_layout.should_show_selected_below(width, desc_col)) + .then(|| combined_description(row, description_layout)) + .flatten(); let name_prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())); // Category tags identify the result even when secondary descriptions hide. // Reserve their cells before truncating the name on narrow rows. diff --git a/codex-rs/tui/src/chatwidget/config_panel.rs b/codex-rs/tui/src/chatwidget/config_panel.rs index c96b8a1269..7195855e78 100644 --- a/codex-rs/tui/src/chatwidget/config_panel.rs +++ b/codex-rs/tui/src/chatwidget/config_panel.rs @@ -6,6 +6,8 @@ use crate::config_panel::ConfigEditorHandoff; use crate::config_panel::ConfigPanel; use crate::config_panel::ConfigPanelStatus; use crate::config_panel::ConfigPanelUpdate; +use crate::config_panel::ConfigPreference; +use crate::config_panel::ConfigToggle; use crossterm::event::KeyEvent; impl ChatWidget { @@ -25,6 +27,11 @@ impl ChatWidget { ) { match handoff.editor { ConfigEditor::Theme => self.open_theme_picker(), + ConfigEditor::Effects => { + let keymap = self.bottom_pane.list_keymap(); + let params = crate::config_panel::effects(&self.local_settings.tui, &keymap); + self.bottom_pane.show_selection_view(params); + } ConfigEditor::StatusLine => self.open_status_line_setup(), ConfigEditor::TerminalTitle => self.open_terminal_title_setup(), ConfigEditor::Keyboard => self.open_keymap_picker(), @@ -72,18 +79,66 @@ impl ChatWidget { self.bottom_pane.interrupt_config_editor_transition(); } + pub(crate) fn apply_config_preference( + &mut self, + preference: ConfigPreference, + tui: &codex_config::types::Tui, + ) { + preference.adopt(&mut self.local_settings.tui, tui); + let tui = &self.local_settings.tui; + let ConfigPreference::Toggle { setting, .. } = preference else { + return; + }; + match setting { + ConfigToggle::Animations | ConfigToggle::ComposerStars => { + self.bottom_pane.set_motion_preferences(tui); + let model = self.current_model().to_string(); + self.bottom_pane.stop_ineligible_sparkle(&model, tui); + self.bottom_pane.select_sparkle_model(&model, tui); + } + ConfigToggle::PasteDetection => self.bottom_pane.set_disable_paste_burst( + /*disabled*/ tui.disable_paste_burst.unwrap_or(/*default*/ false), + ), + ConfigToggle::AutomaticRecaps + | ConfigToggle::CopyOnSelectClearSelection + | ConfigToggle::Notifications + | ConfigToggle::StartInVimMode + | ConfigToggle::StatusLineColors + | ConfigToggle::Tips => {} + } + if setting == ConfigToggle::Animations + && let Some(pet) = self.ambient_pet.as_mut() + { + pet.set_animations_enabled(tui.animations); + } + } + pub(crate) fn observe_config_editor_transition_key_event(&mut self, key: KeyEvent) { self.bottom_pane .observe_config_editor_transition_key_event(key); } pub(crate) fn refresh_config_panel(&mut self, notice: Option) -> bool { - self.bottom_pane.update_config_panel(&ConfigPanelUpdate { + let panel_updated = 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, - }) + notice: notice.clone(), + }); + let Some(selected_idx) = self + .bottom_pane + .selected_index_for_active_view(crate::config_panel::EFFECTS_VIEW_ID) + else { + return panel_updated; + }; + let keymap = self.bottom_pane.list_keymap(); + let mut params = crate::config_panel::effects(&self.config_panel_tui, &keymap); + params.initial_selected_idx = Some(selected_idx); + params.subtitle = notice.or(params.subtitle); + let effects_updated = self + .bottom_pane + .replace_selection_view_if_active(crate::config_panel::EFFECTS_VIEW_ID, params); + panel_updated || effects_updated } fn config_panel_status(&self) -> ConfigPanelStatus { diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index cd4ffdb241..80c62f7e8f 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -172,7 +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.refresh_config_panel(/*notice*/ None); self.update_realtime_footer(); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d083177e00..8534d753f6 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -307,6 +307,7 @@ mod usage; mod worktree_picker; pub(crate) use helpers::make_chatwidget_manual_with_sender; +pub(crate) use helpers::render_bottom_popup; pub(crate) use helpers::set_chatgpt_auth; pub(crate) use helpers::set_fast_mode_test_catalog; pub(super) use helpers::*; diff --git a/codex-rs/tui/src/config_panel/config_panel_tests.rs b/codex-rs/tui/src/config_panel/config_panel_tests.rs index 51c53b9424..32e1c67c9f 100644 --- a/codex-rs/tui/src/config_panel/config_panel_tests.rs +++ b/codex-rs/tui/src/config_panel/config_panel_tests.rs @@ -70,6 +70,14 @@ fn config_panel_renders_each_group_at_a_stable_height() { for section in ConfigSection::ALL { assert_eq!(panel.picker.active_tab_id(), Some(section.title())); assert_eq!(panel.desired_height(/*width*/ 140), height); + let narrow_height = panel.desired_height(/*width*/ 50); + panel + .picker + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(panel.desired_height(/*width*/ 50), narrow_height); + panel + .picker + .handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); insta::assert_snapshot!( format!("config_{}", section.title()), render(&panel, /*width*/ 140) @@ -80,6 +88,36 @@ fn config_panel_renders_each_group_at_a_stable_height() { } } +#[test] +fn effects_picker_groups_animation_settings() { + let (tx, mut rx) = unbounded_channel(); + let keymap = RuntimeKeymap::defaults().list; + let mut view = ListSelectionView::new( + effects(&Tui::default(), &keymap), + AppEventSender::new(tx), + keymap, + ); + + insta::assert_snapshot!("effects_picker", render(&view, /*width*/ 80)); + view.handle_key_event(KeyCode::Enter.into()); + assert!(!view.is_complete()); + assert!(matches!( + rx.try_recv().expect("save animation event"), + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::Animations, + enabled: true, + }) + )); + view.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().expect("save animation event"), + AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting: ConfigToggle::Animations, + enabled: false, + }) + )); +} + #[test] fn deselect_after_copy_requires_effective_copy_on_select() { let (mut panel, mut rx) = self::panel(); @@ -97,7 +135,7 @@ fn deselect_after_copy_requires_effective_copy_on_select() { }); insta::assert_snapshot!( "deselect_after_copy_unavailable", - render(&panel, /*width*/ 140) + render(&panel, /*width*/ 50) ); panel.handle_key_event(KeyCode::Enter.into()); assert!(rx.try_recv().is_err()); @@ -156,19 +194,31 @@ fn notification_controls_render_dependency_states_and_condition_picker() { /*custom_notification_filters*/ false, ); panel.handle_key_event(KeyCode::Right.into()); + panel.handle_key_event(KeyCode::Down.into()); insta::assert_snapshot!("config_notifications_off", render(&panel, /*width*/ 100)); } #[test] -fn filtered_selection_opens_the_matching_existing_editor() { +fn search_finds_settings_across_groups_and_preserves_the_selected_group() { let (mut panel, mut rx) = panel(); panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); - assert!(panel.handle_paste("terminal title".to_string())); + assert!(panel.handle_paste("notify when".to_string())); + insta::assert_snapshot!("config_global_search", render(&panel, /*width*/ 100)); + + panel.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_eq!(panel.picker.active_tab_id(), Some("Notifications")); + assert_eq!(panel.picker.search_query(), Some("notify when")); + panel.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert_eq!(panel.picker.active_tab_id(), Some("Notifications")); + assert_eq!(panel.picker.search_query(), None); + + panel.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + assert!(panel.handle_paste("composer stars".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 + AppEvent::OpenConfigEditor(handoff) if handoff.editor == ConfigEditor::Effects )); assert!(rx.try_recv().is_err()); assert!(!panel.is_complete()); @@ -210,6 +260,7 @@ fn on_demand_search_preserves_printable_list_bindings() { let mut buffer = Buffer::empty(area); panel.render(area, &mut buffer); assert!(panel.cursor_pos(area).is_some()); + assert!(render(&panel, /*width*/ 84).contains("enter open · esc clear search")); panel.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert!(matches!( rx.try_recv().expect("open theme editor event"), @@ -320,9 +371,7 @@ fn unavailable_right_click_paste_remains_editable() { AppEventSender::new(tx), RuntimeKeymap::defaults().list, ConfigPanelStatus { - right_click_paste: Err( - "Unavailable in inline mode. Use your terminal's paste command.", - ), + right_click_paste: Err("Unavailable in inline mode; use your terminal's paste command"), ..STATUS }, ); @@ -392,12 +441,12 @@ fn resume_directory_disables_current_without_remote_cwd_override() { 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())); + assert!(panel.handle_paste("tips".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, + setting: ConfigToggle::Tips, enabled: true, }) )); @@ -407,21 +456,21 @@ fn refresh_preserves_search_and_updates_visible_state() { keymap.move_right.clear(); panel.update_config_panel(&ConfigPanelUpdate { tui: Tui { - animations: true, + show_tooltips: true, ..Default::default() }, keymap, status: STATUS, - notice: Some("Saved · restart Codex to apply".to_string()), + notice: Some("Test sent · check terminal/system settings".to_string()), }); - assert_eq!(panel.picker.search_query(), Some("animations")); + assert_eq!(panel.picker.search_query(), Some("tips")); 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, + setting: ConfigToggle::Tips, enabled: false, }) )); diff --git a/codex-rs/tui/src/config_panel/input.rs b/codex-rs/tui/src/config_panel/input.rs index d14bf0ad69..a3202b8b1b 100644 --- a/codex-rs/tui/src/config_panel/input.rs +++ b/codex-rs/tui/src/config_panel/input.rs @@ -18,6 +18,13 @@ use crossterm::event::KeyEventKind; impl ConfigPanel { fn setting_index(&self, selected: usize) -> Option { + if self + .picker + .search_query() + .is_some_and(|query| !query.is_empty()) + { + return self.settings.get(selected).map(|_| selected); + } let tab = self .picker .active_tab_id() diff --git a/codex-rs/tui/src/config_panel/mod.rs b/codex-rs/tui/src/config_panel/mod.rs index 0f1e96655d..f20f824e8d 100644 --- a/codex-rs/tui/src/config_panel/mod.rs +++ b/codex-rs/tui/src/config_panel/mod.rs @@ -6,7 +6,9 @@ mod input; mod render; mod toggle; +pub(crate) use render::EFFECTS_VIEW_ID; pub(crate) use render::copy_on_select; +pub(crate) use render::effects; pub(crate) use render::notification_condition; pub(crate) use render::resume_directory; pub(crate) use render::right_click_paste; @@ -31,8 +33,7 @@ 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."; +const RIGHT_CLICK_PASTE_DESCRIPTION: &str = "Paste when nothing is selected, with Auto following platform defaults and On also enabling macOS"; pub(crate) struct ConfigPanelUpdate { pub(crate) tui: Tui, @@ -52,6 +53,7 @@ struct PickerState { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ConfigEditor { Theme, + Effects, StatusLine, TerminalTitle, Keyboard, @@ -174,7 +176,9 @@ impl ConfigEditor { NotificationCondition::Always => "Always", } .to_string(), - Self::StatusLine | Self::TerminalTitle | Self::Keyboard => "Configure".to_string(), + Self::Effects | Self::StatusLine | Self::TerminalTitle | Self::Keyboard => { + "Configure".to_string() + } }; ConfigSettingValue::Editor { label, @@ -220,16 +224,6 @@ struct ConfigSetting { 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, @@ -261,7 +255,7 @@ impl ConfigSettingValue { .. } => if *enabled { "[x] On" } else { "[ ] Off" }.to_string(), Self::Editor { label, .. } => format!("{label} ›"), - Self::NotificationTest => "Send in 5s ›".to_string(), + Self::NotificationTest => "Send in 5s".to_string(), Self::DependentToggle { dependency_met: false, .. @@ -280,36 +274,21 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::Appearance, label: "Theme", - description: "Choose a syntax color palette with a live preview.", + 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.", - }, + label: "Effects", + description: "Configure animations and composer effects", + keywords: "animations motion accessibility spinner starfield stars composer stars", + value: ConfigEditor::Effects.value(tui, status), }, ConfigSetting { section: ConfigSection::Appearance, label: "Tips", - description: "Show helpful shortcuts and suggestions. Restart Codex to apply.", + description: "Show helpful shortcuts and suggestions", keywords: "welcome hints tooltips", value: ConfigSettingValue::Toggle { setting: ConfigToggle::Tips, @@ -319,7 +298,7 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::Appearance, label: "Status line colors", - description: "Use theme colors for the model, branch, and other status line fields.", + description: "Use theme colors for the model, branch, and other status line details", keywords: "statusline footer colour", value: ConfigSettingValue::Toggle { setting: ConfigToggle::StatusLineColors, @@ -328,26 +307,26 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { }, ConfigSetting { section: ConfigSection::Appearance, - label: "Status line fields", - description: "Choose and reorder the information below the composer.", + label: "Status line display", + 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.", + label: "Terminal title display", + 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.", + 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.", + reason: "Event-specific notifications are configured in config.toml", } } else { ConfigSettingValue::Toggle { @@ -359,34 +338,34 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::Notifications, label: "Notify when", - description: "Only notify while you are away, or allow notifications while this terminal is focused too.", + description: "Notify only while you are away or 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.", + 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.", + description: "Send in 5 seconds so you can switch apps before the notification appears", keywords: "alerts bell sound test", value: ConfigSettingValue::NotificationTest, }, ConfigSetting { section: ConfigSection::Input, label: "Keyboard shortcuts", - description: "Browse actions and remap their keys.", + 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.", + description: "Start new composers in Vim mode and use /vim for the current composer", keywords: "editor modal normal", value: ConfigSettingValue::Toggle { setting: ConfigToggle::StartInVimMode, @@ -396,7 +375,7 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::Input, label: "Paste detection", - description: "Recognize pasted-text bursts and keep multiline input together. Restart Codex to apply.", + description: "Recognize pasted-text bursts and keep multiline input together", keywords: "burst clipboard multiline", value: ConfigSettingValue::Toggle { setting: ConfigToggle::PasteDetection, @@ -406,7 +385,7 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::Sessions, label: "Automatic recaps", - description: "Prepare recaps while the terminal is unfocused. Restart Codex to apply.", + description: "Prepare recaps while the terminal is unfocused", keywords: "summary background", value: ConfigSettingValue::Toggle { setting: ConfigToggle::AutomaticRecaps, @@ -416,34 +395,34 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::Sessions, label: "Session picker layout", - description: "Choose tighter rows or a roomier list when resuming and forking conversations.", + 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.", + 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.", + description: "Copy transcript selections on release, with Auto following 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.", + 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.", + unavailable_reason: "Turn on Copy on select to deselect after copying", }, }, ConfigSetting { diff --git a/codex-rs/tui/src/config_panel/render.rs b/codex-rs/tui/src/config_panel/render.rs index a906b96f68..4be06a8ce5 100644 --- a/codex-rs/tui/src/config_panel/render.rs +++ b/codex-rs/tui/src/config_panel/render.rs @@ -5,6 +5,7 @@ use super::ConfigPreference; use super::ConfigSection; use super::ConfigSetting; use super::ConfigSettingValue; +use super::ConfigToggle; use super::PickerState; use super::ResumeCurrentAvailability; use crate::app_event::AppEvent; @@ -12,7 +13,9 @@ 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::SELECTION_TOGGLE_UNAVAILABLE_PREFIX; use crate::bottom_pane::SearchMode; +use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionTab; @@ -31,8 +34,72 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use unicode_width::UnicodeWidthStr; +pub(crate) const EFFECTS_VIEW_ID: &str = "config-effects"; + +pub(crate) fn effects(tui: &codex_config::types::Tui, keymap: &ListKeymap) -> SelectionViewParams { + let footer_hint = [(ListAction::Accept, "toggle"), (ListAction::Cancel, "back")] + .into_iter() + .filter_map(|(action, label)| { + keymap + .primary_hint(action) + .map(|hint| (hint.display_label(), label.into())) + }) + .collect::>(); + let settings = [ + ( + "Animations", + "Animate the welcome screen, working indicator, and transitions", + ConfigToggle::Animations, + ), + ( + "Composer stars", + "Show stars briefly for new Astra tasks", + ConfigToggle::ComposerStars, + ), + ]; + SelectionViewParams { + view_id: Some(EFFECTS_VIEW_ID), + title: Some("Effects".to_string()), + subtitle: Some("Configure visual effects".to_string()), + footer_hint: Some(footer_hint_items_line(&footer_hint)), + allow_input_when_disconnected: true, + items: settings + .into_iter() + .map(|(label, description, setting)| { + let enabled = setting.enabled(tui); + let next_enabled = AtomicBool::new(!enabled); + let available = setting != ConfigToggle::ComposerStars || tui.animations; + SelectionItem { + name: label.to_string(), + toggle_placeholder: Some(if !available { + SELECTION_TOGGLE_UNAVAILABLE_PREFIX + } else if enabled { + "[x] " + } else { + "[ ] " + }), + description: available.then(|| description.to_string()), + disabled_reason: (!available) + .then(|| "Turn on Animations to show Astra task stars".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveConfigPreference(ConfigPreference::Toggle { + setting, + enabled: next_enabled.fetch_xor(/*val*/ true, Ordering::Relaxed), + })); + })], + dismiss_on_select: false, + ..Default::default() + } + }) + .collect(), + ..SelectionViewParams::picker() + } +} + pub(crate) fn notification_condition( tui: &codex_config::types::Tui, keymap: &ListKeymap, @@ -47,8 +114,7 @@ pub(crate) fn notification_condition( 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(), + "Notify only while you are away or while this terminal is focused too".to_string(), ), footer_hint: Some(picker_hint_line_for_keymap(keymap)), initial_selected_idx: conditions @@ -88,7 +154,8 @@ pub(crate) fn copy_on_select( SelectionViewParams { title: Some("Copy on select".to_string()), subtitle: Some( - "Copy transcript selections on release. Auto uses terminal defaults.".to_string(), + "Copy transcript selections on release, with Auto following terminal defaults" + .to_string(), ), footer_hint: Some(picker_hint_line_for_keymap(keymap)), initial_selected_idx: modes.iter().position(|(_, mode)| *mode == current), @@ -161,7 +228,7 @@ pub(crate) fn session_picker_layout( SelectionViewParams { title: Some("Session picker layout".to_string()), subtitle: Some( - "Choose tighter rows or a roomier list when resuming and forking conversations." + "Choose tighter rows or a roomier list when resuming and forking conversations" .to_string(), ), footer_hint: Some(picker_hint_line_for_keymap(keymap)), @@ -201,7 +268,7 @@ pub(crate) fn resume_directory( 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." + "Choose the working directory when resuming, with --cd taking precedence for this session" .to_string(), ), footer_hint: Some(picker_hint_line_for_keymap(keymap)), @@ -218,7 +285,7 @@ pub(crate) fn resume_directory( ResumeCurrentAvailability::RequiresCwdOverride ) { - Some("Requires --cd for remote sessions.".to_string()) + Some("Requires --cd for remote sessions".to_string()) } else { None }, @@ -251,15 +318,19 @@ impl Renderable for ConfigPanelHeader { } } -fn header(summary: String) -> Box { +fn header(notice: Option) -> Box { + let subtitle = notice.unwrap_or_else(|| "Changes save automatically".to_string()); Box::new(ConfigPanelHeader(vec![ Line::from("Configuration".bold()), - Line::from("Changes save automatically; some apply after restarting Codex.".dim()), - Line::from(summary.dim()), + Line::from(subtitle.dim()), ])) } -fn hint(keymap: &ListKeymap, search: Option) -> Line<'static> { +fn hint( + keymap: &ListKeymap, + search: Option, + accept_label: Option<&str>, +) -> Line<'static> { let key_label = |action| { keymap.primary_hint(action).map(|hint| { hint.display_label() @@ -280,28 +351,80 @@ fn hint(keymap: &ListKeymap, search: Option) -> Line<'static> { 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()))), + accept_label + .map(|label| (ListAction::Accept, label)) + .into_iter() + .chain([(ListAction::Cancel, "clear search/close")]) + .filter_map(|(action, label)| key_label(action).map(|key| (key, label.into()))), ); footer_hint_items_line(&items) } +#[derive(Clone, Copy)] +enum SettingItemContext { + Group, + Search, +} + +fn setting_item( + setting: &ConfigSetting, + keymap: &ListKeymap, + search_hint: Option, + context: SettingItemContext, +) -> SelectionItem { + let (unavailable, accept_label) = match &setting.value { + ConfigSettingValue::DependentToggle { + dependency_met: false, + unavailable_reason, + .. + } => (Some(*unavailable_reason), None), + ConfigSettingValue::Unavailable { reason } => (Some(*reason), None), + ConfigSettingValue::Toggle { .. } | ConfigSettingValue::DependentToggle { .. } => { + (None, Some("toggle")) + } + ConfigSettingValue::Editor { .. } => (None, Some("open")), + ConfigSettingValue::NotificationTest => (None, Some("test")), + }; + let value_label = setting.value.label(); + let description = unavailable.unwrap_or(setting.description); + let search_footer_hint = footer_hint_items_line( + &accept_label + .map(|label| ("enter".to_string(), label.to_string())) + .into_iter() + .chain([("esc".to_string(), "clear search".to_string())]) + .collect::>(), + ); + let description = match context { + SettingItemContext::Group => description.to_string(), + SettingItemContext::Search => { + format!("{} · {description}", setting.section.title()) + } + }; + SelectionItem { + name: setting.label.to_string(), + category_tag: Some(format!("{value_label:<20}")), + description: Some(description), + is_disabled: unavailable.is_some(), + footer_hint: Some(hint(keymap, search_hint, accept_label)), + search_footer_hint: Some(search_footer_hint), + search_value: Some(format!( + "{} {} {} {} {}", + setting.section.title(), + setting.label, + value_label, + setting.keywords, + setting.description + )), + ..Default::default() + } +} + 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); @@ -323,10 +446,17 @@ pub(super) fn picker( reserve_result_rows: true, row_display: SelectionRowDisplay::SingleLine, col_width_mode: ColumnWidthMode::AutoAllRows, - footer_hint: Some(hint(&keymap, search_hint)), + description_layout: SelectionDescriptionLayout::SelectedBelowWhenNarrow { + min_description_width: 45, + }, + footer_hint: Some(hint(&keymap, search_hint, /*accept_label*/ None)), is_searchable: true, search_mode: SearchMode::OnDemand(search), search_placeholder: Some("Type to search settings".to_string()), + search_items: settings + .iter() + .map(|setting| setting_item(setting, &keymap, search_hint, SettingItemContext::Search)) + .collect(), name_column_width: settings .iter() .map(|setting| UnicodeWidthStr::width(setting.label)) @@ -334,49 +464,16 @@ pub(super) fn picker( tabs: ConfigSection::ALL .iter() .map(|section| { - let group = settings - .iter() - .filter(|setting| setting.section == *section) - .collect::>(); let title = section.title(); SelectionTab { id: title.to_string(), label: title.to_string(), - header: header(summary(group.len())), - items: group - .into_iter() + header: header(state.notice.clone()), + items: settings + .iter() + .filter(|setting| setting.section == *section) .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() - } + setting_item(setting, &keymap, search_hint, SettingItemContext::Group) }) .collect(), } diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap index c38ceb2ad6..f8681dc8af 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Appearance.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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. +› Theme Automatic › Choose a syntax color palette with a live preview + Effects Configure › Configure animations and composer effects + Tips [ ] Off Show helpful shortcuts and suggestions + Status line colors [ ] Off Use theme colors for the model, branch, and other status line details + Status line display Configure › Choose and reorder the information below the composer + Terminal title display Configure › Choose and reorder the information in your terminal tab or window title - left/right group · / search · enter edit setting · esc close + left/right group · / search · enter open · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap index 4f5fe9f595..af37dce8dc 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Input.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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. +› Keyboard shortcuts Configure › Browse actions and remap their keys + Start in Vim mode [ ] Off Start new composers in Vim mode and use /vim for the current composer + Paste detection [x] On Recognize pasted-text bursts and keep multiline input together - - left/right group · / search · enter edit setting · esc close + left/right group · / search · enter open · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap index 01d2a8bf12..a243ba85b1 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Notifications.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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. +› Notifications [x] On Let your terminal notify you when a turn finishes or approval is needed + Notify when Unfocused › Notify only while you are away or while this terminal is focused too + Test notification Send in 5s Send in 5 seconds so you can switch apps before the notification appears - - left/right group · / search · enter edit setting · esc close + left/right group · / search · enter toggle · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap index 8e3ad4ee2b..be1c9abf60 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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. +› Automatic recaps [ ] Off Prepare recaps while the terminal is unfocused + 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 + left/right group · / search · enter toggle · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap index df721cb00b..69f6e040f9 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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. +› Copy on select Auto · On › Copy transcript selections on release, with Auto following 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, with Auto following platform defaults and On also enabling mac… - - left/right group · / search · enter edit setting · esc close + left/right group · / search · enter open · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap index 8b7dd84ffb..5e0f84a618 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap @@ -2,8 +2,9 @@ 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. + Copy transcript selections on release, with Auto following terminal defaults › 1. Auto (default) (current) diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_global_search.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_global_search.snap new file mode 100644 index 0000000000..25b8064cba --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_global_search.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +assertion_line: 206 +expression: "render(&panel, 100)" +--- + + Configuration + Changes save automatically + + Appearance Notifications Input Sessions Text selection + + Search › notify when + +› Notify when Unfocused › Notifications · Notify only while you are away or whil… + + + + + + + enter open · esc clear search diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap index efdda55361..a2c6112509 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notification_condition_choice.snap @@ -2,8 +2,9 @@ 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. + Notify only while you are away or while this terminal is focused too › 1. Unfocused (current) diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap index 88ab7f1bb6..2286d41992 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_custom.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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… + Notifications Unavailable Event-specific notifications are configured in config.… +› Notify when Unfocused › Notify only while you are away or while this terminal … + Test notification Send in 5s Send in 5 seconds so you can switch apps before the no… - - left/right group · / search · enter edit setting · esc close + left/right group · / search · enter open · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap index 4552fad628..76baec1a3c 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_notifications_off.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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… + Notifications [ ] Off Let your terminal notify you when a turn finishes or a… + Notify when Unavailable Turn on notifications to choose when they appear +› Test notification Send in 5s Send in 5 seconds so you can switch apps before the no… - - left/right group · / search · enter edit setting · esc close + left/right group · / search · enter test · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap index 2aa334d3c5..c1cb2e864e 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_panel_wraps_complete_save_error.snap @@ -1,24 +1,23 @@ --- 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… +› Theme Automatic › + Effects Configure › + Tips [ ] Off + Status line colors [ ] Off + Status line display Configure › + Terminal title display Configure › - left/right group · / search · enter edit setting - · esc close + Choose a syntax color palette with a live + preview + + left/right group · / search · enter open · esc + clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap index dbbcc1344d..d5c016c409 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_resume_directory_remote.snap @@ -4,12 +4,11 @@ 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. + Choose the working directory when resuming, with --cd taking precedence for this session › 1. Ask (current) - Current directory (disabled) Requires --cd for remote sessions. + Current directory (disabled) Requires --cd for remote sessions 2. Session directory enter select · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap index 392e2a5655..1e04e5db58 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_choice.snap @@ -2,8 +2,9 @@ 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. + Paste when nothing is selected, with Auto following platform defaults and On also enabling macOS › 1. Auto (default) (current) diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap index 5e55c3fb13..89c163c39a 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap @@ -1,21 +1,18 @@ --- 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. + Changes save automatically 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. + Copy on select Auto · On › Copy transcript selections on release, with Auto following 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 + left/right group · / search · enter open · esc clear search/close diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap index daefa032bf..199002c9a2 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_session_picker_layout_choice.snap @@ -2,8 +2,9 @@ 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. + Choose tighter rows or a roomier list when resuming and forking conversations › 1. Dense (current) diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap index 10c5f38062..461647430f 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__deselect_after_copy_unavailable.snap @@ -1,23 +1,24 @@ --- source: tui/src/config_panel/config_panel_tests.rs -assertion_line: 96 -expression: "render(&panel, 140)" +assertion_line: 128 +expression: "render(&panel, 50)" --- Configuration - Changes save automatically; some apply after restarting Codex. - Settings in this group: 3. + Changes save automatically - Appearance Notifications Input Sessions Text selection + ‹ Input Sessions Text selection Search › deselect after copy -› Deselect after copy Unavailable Turn on Copy on select to deselect after copying. +› Deselect after copy Unavailable + Text selection · Turn on Copy on select to + deselect after copying - left/right group · / search · enter edit setting · esc close + esc clear search diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__effects_picker.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__effects_picker.snap new file mode 100644 index 0000000000..866d4f8c5f --- /dev/null +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__effects_picker.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/config_panel/config_panel_tests.rs +expression: "render(&view, 80)" +--- + + Effects + Configure visual effects + + +› 1. [ ] Animations Animate the welcome screen, working + indicator, and transitions + [-] Composer stars (disabled) Turn on Animations to show Astra task stars + + enter toggle · esc back diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap index ce877485a0..403fac62f9 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__refresh_preserves_search_and_updates_visible_state.snap @@ -1,23 +1,21 @@ --- source: tui/src/config_panel/config_panel_tests.rs -assertion_line: 212 +assertion_line: 477 expression: "render(&panel, 140)" --- Configuration - Changes save automatically; some apply after restarting Codex. - Saved · restart Codex to apply + Test sent · check terminal/system settings Appearance Notifications Input Sessions Text selection - Search › animations + Search › tips -› 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. +› Tips [x] On Appearance · Show helpful shortcuts and suggestions - / search · enter edit setting · esc close + enter toggle · esc clear search diff --git a/codex-rs/tui/src/config_panel/toggle.rs b/codex-rs/tui/src/config_panel/toggle.rs index 50d7618b3c..71dd1fa4a8 100644 --- a/codex-rs/tui/src/config_panel/toggle.rs +++ b/codex-rs/tui/src/config_panel/toggle.rs @@ -89,24 +89,6 @@ impl ConfigPreference { } 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 => { diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index fd06387bfd..a9bed99796 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -7,7 +7,6 @@ //! 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; @@ -208,7 +207,13 @@ 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); + // Generic reloads do not update these values in the live UI. The config panel updates + // both copies when the user changes one. + settings.tui.animations = self.tui.animations; + settings.tui.auto_recap = self.tui.auto_recap; + settings.tui.effects.starfield = self.tui.effects.starfield; + settings.tui.disable_paste_burst = self.tui.disable_paste_burst; + settings.tui.show_tooltips = self.tui.show_tooltips; settings } diff --git a/codex-rs/tui/src/pets/ambient.rs b/codex-rs/tui/src/pets/ambient.rs index 3427f0a4c0..e5507ce5c9 100644 --- a/codex-rs/tui/src/pets/ambient.rs +++ b/codex-rs/tui/src/pets/ambient.rs @@ -180,6 +180,10 @@ impl AmbientPet { self.animation_started_at = Instant::now(); } + pub(crate) fn set_animations_enabled(&mut self, enabled: bool) { + self.animations_enabled = enabled; + } + pub(crate) fn image_enabled(&self) -> bool { self.support.protocol().is_some() } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index b3e5edec98..3cb54b2cf7 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -115,6 +115,11 @@ impl StatusIndicatorWidget { self.app_event_tx.interrupt(); } + pub(crate) fn set_motion_preferences(&mut self, tui: &codex_config::types::Tui) { + self.animations_enabled = tui.animations; + self.effects = tui.effects; + } + /// Update the animated header label (left of the brackets). pub(crate) fn update_header(&mut self, header: String) { if self.header != header {