Add keybindings for cycling TUI permission modes (#39873)

## What changed

- Add unbound `previous_permission_mode` and `next_permission_mode` actions to
  `tui.keymap.chat` so users can configure shortcuts that cycle through the
  available built-in permission modes.
- Apply shortcut selections only to the active session, without writing them
  to `config.toml`, and report update failures in the transcript.
- Exclude modes disallowed by managed requirements or platform safety checks,
  reject printable-key bindings, and ignore these bindings in project config.

## Testing

- Cover mode cycling, unavailable modes, session-only updates, server errors,
  stale threads, keymap validation, and project-config sanitization.

GitOrigin-RevId: c3cfdf1e732b7b7bfe8f53fcac19a37e593bbfab
This commit is contained in:
Eric Traut
2026-08-21 06:19:23 +00:00
committed by copyberry
parent 44e95c857f
commit 2aaefa32b0
32 changed files with 708 additions and 47 deletions
+13
View File
@@ -1127,6 +1127,19 @@ fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
{
ignored_keys.push("features.respect_system_proxy".to_string());
}
// Repository contents must not turn an ordinary key into a permission increase.
if let Some(chat) = table
.get_mut("tui")
.and_then(|tui| tui.get_mut("keymap"))
.and_then(|keymap| keymap.get_mut("chat"))
.and_then(TomlValue::as_table_mut)
{
for key in ["previous_permission_mode", "next_permission_mode"] {
if chat.remove(key).is_some() {
ignored_keys.push(format!("tui.keymap.chat.{key}"));
}
}
}
ignored_keys
}
+13
View File
@@ -19,6 +19,19 @@ use tempfile::tempdir;
pub(super) struct TestFileSystem;
#[test]
fn project_config_cannot_bind_permission_shortcuts() {
let safe = "[tui.keymap.chat]\nincrease_reasoning_effort = 'f9'\n";
for key in ["previous_permission_mode", "next_permission_mode"] {
let mut config = toml::from_str(&format!("{safe}{key} = 'page-down'")).unwrap();
assert_eq!(
sanitize_project_config(&mut config),
[format!("tui.keymap.chat.{key}")]
);
assert_eq!(config, toml::from_str::<TomlValue>(safe).unwrap());
}
}
#[tokio::test]
async fn managed_browser_import_denial_survives_user_and_session_config() {
let tmp = tempdir().expect("tempdir");
+4
View File
@@ -129,6 +129,10 @@ pub struct TuiChatKeymap {
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
/// Increase the active reasoning effort.
pub increase_reasoning_effort: Option<KeybindingsSpec>,
/// Switch to the previous available permission mode.
pub previous_permission_mode: Option<KeybindingsSpec>,
/// Switch to the next available permission mode.
pub next_permission_mode: Option<KeybindingsSpec>,
/// Edit the most recently queued message.
pub edit_queued_message: Option<KeybindingsSpec>,
}
+22 -2
View File
@@ -3571,7 +3571,9 @@
"decrease_reasoning_effort": null,
"edit_queued_message": null,
"increase_reasoning_effort": null,
"interrupt_turn": null
"interrupt_turn": null,
"next_permission_mode": null,
"previous_permission_mode": null
},
"composer": {
"history_search_next": null,
@@ -3967,6 +3969,22 @@
}
],
"description": "Interrupt the active turn."
},
"next_permission_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Switch to the next available permission mode."
},
"previous_permission_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Switch to the previous available permission mode."
}
},
"type": "object"
@@ -4309,7 +4327,9 @@
"decrease_reasoning_effort": null,
"edit_queued_message": null,
"increase_reasoning_effort": null,
"interrupt_turn": null
"interrupt_turn": null,
"next_permission_mode": null,
"previous_permission_mode": null
}
},
"composer": {
+1
View File
@@ -223,6 +223,7 @@ mod history_ui;
mod input;
mod loaded_threads;
mod pending_interactive_replay;
mod permission_shortcuts;
mod pets;
mod platform_actions;
mod plugin_mentions;
+3
View File
@@ -1519,6 +1519,9 @@ impl App {
profile_selection,
);
}
AppEvent::ApplyPermissionShortcut { thread_id, selection } => {
self.apply_permission_shortcut(app_server, tui, thread_id, selection).await;
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
profile_selection,
@@ -0,0 +1,71 @@
//! Confirm session-only permission changes before updating local state.
use super::*;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
impl App {
pub(super) async fn apply_permission_shortcut(
&mut self,
app_server: &mut AppServerSession,
tui: &mut tui::Tui,
thread_id: ThreadId,
selection: PermissionProfileSelection,
) {
if self.current_displayed_thread_id() != Some(thread_id)
|| self.chat_widget.thread_id() != Some(thread_id)
{
self.chat_widget.complete_permission_shortcut(thread_id);
return;
}
let result: Result<()> = async {
let active_profile = ActivePermissionProfile::new(selection.profile_id.clone());
let profile = builtin_permission_profile_for_active_permission_profile(&active_profile)
.ok_or_else(|| color_eyre::eyre::eyre!("unknown built-in permission profile"))?;
let mut config = self.chat_widget.config_ref().clone();
if let Some(policy) = selection.approval_policy {
config.permissions.approval_policy.set(policy.to_core())?;
}
if let Some(reviewer) = selection.approvals_reviewer {
config.config_layer_stack.requirements().approvals_reviewer.can_set(&reviewer)?;
config.approvals_reviewer = reviewer;
}
config.permissions.set_permission_profile_from_session_snapshot(
PermissionProfileSnapshot::active(profile, active_profile),
)?;
if !app_server.thread_settings_update(ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
permissions: Some(selection.profile_id),
approval_policy: selection.approval_policy,
approvals_reviewer: selection.approvals_reviewer.map(Into::into),
..Default::default()
}).await? {
color_eyre::eyre::bail!("this app server does not support confirmed permission changes; use /permissions");
}
// This exact widget configuration was validated before the request.
self.chat_widget.set_permission_profile_with_active_profile(
config.permissions.permission_profile().clone(),
config.permissions.active_permission_profile(),
)?;
self.chat_widget.set_approval_policy(AskForApproval::from(config.permissions.approval_policy.value()));
self.config.permissions = config.permissions.clone();
self.set_approvals_reviewer_in_app_and_widget(config.approvals_reviewer);
self.runtime_approval_policy_override = selection.approval_policy;
self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&config));
self.sync_active_thread_permission_settings_to_cached_session().await;
self.insert_history_cell(tui, Box::new(history_cell::new_info_event(format!("Permissions updated to {}", selection.display_label), /*hint*/ None)));
Ok(())
}.await;
self.chat_widget.complete_permission_shortcut(thread_id);
if let Err(err) = result {
let error = crate::config_update::format_config_error(&err);
self.insert_history_cell(
tui,
Box::new(history_cell::new_error_event(format!(
"Failed to update permissions: {error}"
))),
);
}
}
}
+2
View File
@@ -11,6 +11,8 @@ mod key_chords;
#[path = "tests/mcp_startup.rs"]
mod mcp_startup;
mod model_catalog;
#[path = "tests/permission_shortcuts_tests.rs"]
mod permission_shortcuts_tests;
mod plugin_catalog;
mod rate_limits;
mod safety_buffering;
@@ -0,0 +1,138 @@
use super::*;
use codex_arg0::Arg0DispatchPaths;
use pretty_assertions::assert_eq;
fn read_only_selection() -> PermissionProfileSelection {
PermissionProfileSelection {
profile_id: ":read-only".to_string(),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
display_label: "Read Only".to_string(),
}
}
#[tokio::test]
async fn permission_shortcut_rejections_leave_state_unchanged() -> Result<()> {
for experimental_api in [false, true] {
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.chat_widget
.handle_thread_session_quiet(test_thread_session(
thread_id,
app.config.cwd.to_path_buf(),
));
let original = RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref());
let original_reviewer = app.config.approvals_reviewer;
let client = crate::start_embedded_app_server_with(
Arg0DispatchPaths::default(),
app.config.clone(),
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
CloudConfigBundleLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
/*state_db*/ None,
Arc::clone(&app.environment_manager),
|mut args| {
args.experimental_api = experimental_api;
codex_app_server_client::InProcessAppServerClient::start(args)
},
)
.await?;
let mut app_server = AppServerSession::new(
codex_app_server_client::AppServerClient::InProcess(client),
crate::app_server_session::ThreadParamsMode::Embedded,
);
while events.try_recv().is_ok() {}
let transcript_len = app.transcript_cells.len();
app.apply_permission_shortcut(
&mut app_server,
&mut tui,
ThreadId::new(),
read_only_selection(),
)
.await;
assert_eq!(app.transcript_cells.len(), transcript_len);
assert!(events.try_recv().is_err());
app.apply_permission_shortcut(&mut app_server, &mut tui, thread_id, read_only_selection())
.await;
assert_eq!(
RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()),
original
);
assert_eq!(app.config.approvals_reviewer, original_reviewer);
let cell = app.transcript_cells.last().expect("rejection notice");
insta::assert_snapshot!(
if experimental_api {
"permission_shortcut_server_error"
} else {
"permission_shortcut_unsupported"
},
lines_to_single_string(&cell.display_lines(/*width*/ 120))
.replace(&thread_id.to_string(), "<THREAD_ID>")
);
assert!(events.try_recv().is_err());
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn permission_shortcut_confirms_without_persisting() -> Result<()> {
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(":workspace"),
))?;
let config_path = codex_home.path().join("config.toml");
let contents = "approvals_reviewer = \"auto_review\"\n";
std::fs::write(&config_path, contents)?;
let mut app_server = start_config_write_test_app_server(&app).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.chat_widget
.handle_thread_session_quiet(started.session.clone());
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
let contents = std::fs::read_to_string(&config_path)?;
while events.try_recv().is_ok() {}
app.apply_permission_shortcut(&mut app_server, &mut tui, thread_id, read_only_selection())
.await;
let cell = app.transcript_cells.last().expect("confirmed notice");
insta::assert_snapshot!(
lines_to_single_string(&cell.display_lines(/*width*/ 80)),
@"• Permissions updated to Read Only"
);
let settings = next_thread_settings_updated(&mut app_server, thread_id)
.await
.thread_settings;
let profile = app
.chat_widget
.config_ref()
.permissions
.active_permission_profile();
assert_eq!(
settings.active_permission_profile,
profile.clone().map(Into::into)
);
assert_eq!(profile, Some(ActivePermissionProfile::new(":read-only")));
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
assert_eq!(std::fs::read_to_string(config_path)?, contents);
assert!(
events.try_recv().is_err(),
"must not queue another update or config write"
);
app_server.shutdown().await?;
Ok(())
}
@@ -0,0 +1,6 @@
---
source: tui/src/app/tests/permission_shortcuts_tests.rs
assertion_line: 63
expression: "lines_to_single_string(&cell.display_lines(120)).replace(&missing_thread.to_string(),\n\"<THREAD_ID>\")"
---
■ Failed to update permissions: thread/settings/update failed in TUI: thread/settings/update failed: thread not found: <THREAD_ID> (code -32600)
@@ -0,0 +1,6 @@
---
source: tui/src/app/tests/permission_shortcuts_tests.rs
assertion_line: 33
expression: lines_to_single_string(&cell.display_lines(120))
---
■ Failed to update permissions: this app server does not support confirmed permission changes; use /permissions
+6
View File
@@ -948,6 +948,12 @@ pub(crate) enum AppEvent {
profile_selection: Option<PermissionProfileSelection>,
},
/// Apply a permission shortcut only while its originating thread is displayed.
ApplyPermissionShortcut {
thread_id: ThreadId,
selection: PermissionProfileSelection,
},
/// Open the Windows world-writable directories warning.
/// If `preset` is `Some`, the confirmation will apply the provided
/// approval/sandbox configuration on Continue; if `None`, it performs no
+2
View File
@@ -389,6 +389,7 @@ mod model_popups;
mod notifications;
use self::notifications::Notification;
mod permission_popups;
mod permission_shortcuts;
mod permissions_menu;
pub(crate) use self::permissions_menu::auto_review_available;
pub(crate) use self::permissions_menu::cyber_model_approval_reviewer;
@@ -696,6 +697,7 @@ pub(crate) struct ChatWidget {
safety_buffering_prompt: Option<UserMessage>,
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
chat_keymap: ChatKeymap,
permission_shortcut_pending: bool,
/// Keybinding to show for popping the most-recently queued message back
/// into the composer. This may differ from the first configured binding
/// when the default set includes a terminal-specific fallback.
@@ -211,6 +211,7 @@ impl ChatWidget {
input_queue: InputQueueState::default(),
safety_buffering_prompt: None,
chat_keymap,
permission_shortcut_pending: false,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
+1 -1
View File
@@ -36,7 +36,7 @@ impl ChatWidget {
return;
}
if self.handle_reasoning_shortcut(key_event) {
if self.handle_reasoning_shortcut(key_event) || self.handle_permission_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
@@ -0,0 +1,130 @@
//! Session-scoped shortcuts for the ordinary built-in permission modes.
use super::*;
impl ChatWidget {
pub(super) fn handle_permission_shortcut(&mut self, key_event: KeyEvent) -> bool {
let forward = if self.chat_keymap.next_permission_mode.is_pressed(key_event) {
true
} else if self
.chat_keymap
.previous_permission_mode
.is_pressed(key_event)
{
false
} else {
return false;
};
if !self.bottom_pane.no_modal_or_popup_active() {
return false;
}
if self.permission_shortcut_pending {
return true;
}
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return true;
}
let Some(thread_id) = self.thread_id else {
return true;
};
let current_approval =
AskForApproval::from(self.config.permissions.approval_policy.value());
let active_profile = self.config.permissions.active_permission_profile();
let mut choices = Vec::new();
for preset in builtin_approval_presets() {
if !matches!(preset.id, "read-only" | "auto") {
continue;
}
for reviewer in [ApprovalsReviewer::User, ApprovalsReviewer::AutoReview] {
if reviewer == ApprovalsReviewer::AutoReview
&& (preset.id != "auto"
|| !self.config.features.enabled(Feature::GuardianApproval))
{
continue;
}
let approval = AskForApproval::from(preset.approval);
let requirements = self.config.config_layer_stack.requirements();
if self
.permission_mode_disabled_reason(&preset, approval)
.is_some()
|| requirements.approvals_reviewer.can_set(&reviewer).is_err()
|| (requirements.auto_review_required_for_model(self.current_model())
&& reviewer != ApprovalsReviewer::AutoReview)
{
continue;
}
// These modes still need the explicit Windows setup/warning flow.
#[cfg(target_os = "windows")]
if preset.id == "auto"
&& reviewer == ApprovalsReviewer::User
&& (crate::windows_sandbox::level_from_config(&self.config)
== WindowsSandboxLevel::Disabled
|| self.world_writable_warning_details().is_some())
{
continue;
}
let is_current = current_approval == approval
&& self.config.approvals_reviewer == reviewer
&& active_profile.as_ref().map_or_else(
|| {
Self::preset_matches_current(
current_approval,
self.config.permissions.permission_profile(),
self.config.cwd.as_path(),
&preset,
)
},
|active| active.id == preset.active_permission_profile.id,
);
let label = match (preset.id, reviewer) {
("auto", ApprovalsReviewer::User) => ASK_FOR_APPROVAL_LABEL,
("auto", ApprovalsReviewer::AutoReview) => APPROVE_FOR_ME_LABEL,
_ => preset.label,
};
choices.push((
is_current,
PermissionProfileSelection {
profile_id: preset.active_permission_profile.id.clone(),
approval_policy: Some(approval),
approvals_reviewer: Some(reviewer),
display_label: label.to_string(),
},
));
}
}
if !forward {
choices.reverse();
}
let start = choices
.iter()
.position(|(current, _)| *current)
.map_or(0, |index| index + 1);
if let Some((_, selection)) = choices
.iter()
.cycle()
.skip(start)
.take(choices.len())
.find(|(current, _)| !current)
{
self.permission_shortcut_pending = true;
self.app_event_tx.send(AppEvent::ApplyPermissionShortcut {
thread_id,
selection: selection.clone(),
});
} else {
self.add_info_message(
"No other permission modes are available.".to_string(),
/*hint*/ None,
);
}
true
}
pub(crate) fn complete_permission_shortcut(&mut self, thread_id: ThreadId) {
if self.thread_id == Some(thread_id) {
self.permission_shortcut_pending = false;
}
}
}
+26 -20
View File
@@ -33,6 +33,31 @@ pub(crate) fn cyber_model_approval_reviewer(config: &Config) -> Option<Approvals
}
impl ChatWidget {
pub(super) fn permission_mode_disabled_reason(
&self,
preset: &ApprovalPreset,
approval_policy: AskForApproval,
) -> Option<String> {
self.config
.permissions
.approval_policy
.can_set(&approval_policy.to_core())
.and_then(|()| {
self.config
.permissions
.can_set_permission_profile(&preset.permission_profile)
})
.err()
.map(|err| err.to_string())
.or_else(|| {
(!self.config.is_permission_profile_allowed(
&preset.active_permission_profile.id,
&preset.permission_profile,
))
.then(|| "Disabled by requirements.".to_string())
})
}
pub(super) fn open_permission_profiles_popup(&mut self) {
let active_profile_id = self
.config
@@ -161,26 +186,7 @@ impl ChatWidget {
/*return_to_permissions*/ true,
),
dismiss_on_select: true,
disabled_reason: self
.config
.permissions
.approval_policy
.can_set(&approval_policy.to_core())
.err()
.map(|err| err.to_string())
.or_else(|| {
self.config
.permissions
.can_set_permission_profile(&preset.permission_profile)
.err()
.map(|err| err.to_string())
})
.or_else(|| {
(!self
.config
.is_permission_profile_allowed(id, &preset.permission_profile))
.then(|| "Disabled by requirements.".to_string())
}),
disabled_reason: self.permission_mode_disabled_reason(preset, approval_policy),
..Default::default()
}
}
+2
View File
@@ -245,6 +245,8 @@ mod history_replay;
mod mcp_startup;
#[path = "tests/misalignment_policy_tests.rs"]
mod misalignment_policy;
#[path = "tests/permission_shortcuts_tests.rs"]
mod permission_shortcuts_tests;
mod permissions;
mod plan_mode;
#[path = "tests/plugin_catalog_tests.rs"]
@@ -0,0 +1,133 @@
use super::permissions::requirements_stack;
use super::*;
use ApprovalsReviewer::AutoReview;
use ApprovalsReviewer::User;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn permission_shortcuts_cycle_builtin_modes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true);
chat.chat_keymap.next_permission_mode = vec![crate::key_hint::plain(KeyCode::F(8))];
chat.chat_keymap.previous_permission_mode = vec![crate::key_hint::plain(KeyCode::F(7))];
#[cfg(target_os = "windows")]
{
chat.config.notices.hide_world_writable_warning = Some(true);
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
for (current, reviewer, key, expected, next_reviewer) in [
(":workspace", User, KeyCode::F(8), ":workspace", AutoReview),
(":workspace", AutoReview, KeyCode::F(8), ":read-only", User),
(":read-only", User, KeyCode::F(8), ":workspace", User),
(":read-only", User, KeyCode::F(7), ":workspace", AutoReview),
] {
let profile = if current == ":read-only" {
PermissionProfile::read_only()
} else {
PermissionProfile::workspace_write()
};
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
profile,
ActivePermissionProfile::new(current),
))
.expect("set current profile");
chat.config.approvals_reviewer = reviewer;
chat.handle_key_event(KeyEvent::from(key));
chat.handle_key_event(KeyEvent::from(key));
let AppEvent::ApplyPermissionShortcut {
thread_id: target,
selection,
} = rx.try_recv().expect("permission selection")
else {
panic!("expected one typed permission selection");
};
assert_eq!(
(
target,
selection.profile_id.as_str(),
selection.approval_policy,
selection.approvals_reviewer
),
(
thread_id,
expected,
Some(AskForApproval::OnRequest),
Some(next_reviewer)
)
);
assert!(
rx.try_recv().is_err(),
"pending shortcut must not be duplicated"
);
chat.complete_permission_shortcut(thread_id);
}
#[cfg(target_os = "windows")]
{
chat.set_windows_sandbox_mode(/*mode*/ None);
chat.set_feature_enabled(Feature::WindowsSandbox, /*enabled*/ false);
chat.set_feature_enabled(Feature::WindowsSandboxElevated, /*enabled*/ false);
chat.config
.permissions
.set_permission_profile(PermissionProfile::read_only())
.unwrap();
chat.config.approvals_reviewer = User;
chat.handle_key_event(KeyEvent::from(KeyCode::F(8)));
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::ApplyPermissionShortcut {
selection: PermissionProfileSelection {
approvals_reviewer: Some(AutoReview),
..
},
..
})
));
assert!(rx.try_recv().is_err());
}
}
#[tokio::test]
async fn permission_shortcuts_respect_managed_mode_requirements() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true);
chat.config.approvals_reviewer = AutoReview;
chat.chat_keymap.next_permission_mode = vec![crate::key_hint::plain(KeyCode::F(8))];
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(":workspace"),
))
.expect("set active profile");
for requirements in [
codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![AutoReview]),
..Default::default()
},
codex_config::ConfigRequirementsToml {
auto_review: Some(codex_config::AutoReviewRequirementsToml {
required_on_models: Some(vec![chat.current_model().to_string()]),
..Default::default()
}),
..Default::default()
},
] {
chat.config.config_layer_stack = requirements_stack(requirements);
chat.handle_key_event(KeyEvent::from(KeyCode::F(8)));
let AppEvent::InsertHistoryCell(cell) = rx.try_recv().expect("unavailable-mode notice")
else {
panic!("must not submit a forbidden mode");
};
insta::assert_snapshot!(
"permission_shortcut_no_alternative",
lines_to_single_string(&cell.display_lines(/*width*/ 80))
);
assert!(rx.try_recv().is_err(), "must not submit a forbidden mode");
}
}
@@ -67,7 +67,9 @@ fn windows_sandbox_requirements_stack(
requirements_stack(requirements_toml)
}
fn requirements_stack(requirements_toml: codex_config::ConfigRequirementsToml) -> ConfigLayerStack {
pub(super) fn requirements_stack(
requirements_toml: codex_config::ConfigRequirementsToml,
) -> ConfigLayerStack {
let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default();
requirements_with_sources
.merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone());
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/permission_shortcuts_tests.rs
expression: lines_to_single_string(&cell.display_lines(80))
---
• No other permission modes are available.
+80
View File
@@ -109,6 +109,10 @@ pub(crate) struct ChatKeymap {
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
/// Increase the active reasoning effort.
pub(crate) increase_reasoning_effort: Vec<KeyBinding>,
/// Switch to the previous available permission mode.
pub(crate) previous_permission_mode: Vec<KeyBinding>,
/// Switch to the next available permission mode.
pub(crate) next_permission_mode: Vec<KeyBinding>,
/// Edit the most recently queued message.
pub(crate) edit_queued_message: Vec<KeyBinding>,
}
@@ -648,6 +652,13 @@ impl RuntimeKeymap {
&defaults.chat.increase_reasoning_effort,
"tui.keymap.chat.increase_reasoning_effort",
)?,
previous_permission_mode: resolve_local!(
keymap,
defaults,
chat,
previous_permission_mode
),
next_permission_mode: resolve_local!(keymap, defaults, chat, next_permission_mode),
edit_queued_message: resolve_bindings(
keymap.chat.edit_queued_message.as_ref(),
&defaults.chat.edit_queued_message,
@@ -1211,6 +1222,8 @@ impl RuntimeKeymap {
alt(KeyCode::Char('.')),
shift(KeyCode::Up)
],
previous_permission_mode: default_bindings![],
next_permission_mode: default_bindings![],
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
},
composer: ComposerKeymap {
@@ -1458,6 +1471,23 @@ impl RuntimeKeymap {
/// 2. Contexts with hard-coded sequence behavior, such as edit-previous
/// backtracking, intentionally stay outside this configurable keymap.
fn validate_conflicts(&self) -> Result<(), String> {
for (action, bindings) in [
(
"previous_permission_mode",
&self.chat.previous_permission_mode,
),
("next_permission_mode", &self.chat.next_permission_mode),
] {
if bindings.iter().any(|binding| {
let (code, modifiers) = binding.parts();
crate::key_hint::is_plain_text_key_event(KeyEvent::new(code, modifiers))
|| matches!(code, KeyCode::Char(_)) && crate::key_hint::is_altgr(modifiers)
}) {
return Err(format!(
"tui.keymap.chat.{action}: printable keys are reserved for text input"
));
}
}
#[cfg(unix)]
if self
.app
@@ -1510,6 +1540,14 @@ impl RuntimeKeymap {
"chat.increase_reasoning_effort",
self.chat.increase_reasoning_effort.as_slice(),
),
(
"chat.previous_permission_mode",
self.chat.previous_permission_mode.as_slice(),
),
(
"chat.next_permission_mode",
self.chat.next_permission_mode.as_slice(),
),
(
"chat.edit_queued_message",
self.chat.edit_queued_message.as_slice(),
@@ -1555,6 +1593,14 @@ impl RuntimeKeymap {
"chat.increase_reasoning_effort",
self.chat.increase_reasoning_effort.as_slice(),
),
(
"chat.previous_permission_mode",
self.chat.previous_permission_mode.as_slice(),
),
(
"chat.next_permission_mode",
self.chat.next_permission_mode.as_slice(),
),
(
"chat.edit_queued_message",
self.chat.edit_queued_message.as_slice(),
@@ -1668,6 +1714,14 @@ impl RuntimeKeymap {
"chat.increase_reasoning_effort",
self.chat.increase_reasoning_effort.as_slice(),
),
(
"chat.previous_permission_mode",
self.chat.previous_permission_mode.as_slice(),
),
(
"chat.next_permission_mode",
self.chat.next_permission_mode.as_slice(),
),
("composer.submit", self.composer.submit.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
@@ -2561,6 +2615,32 @@ mod tests {
assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]);
}
#[test]
fn permission_shortcuts_reserve_plain_text() {
for binding in ["a", "shift-a", "2", "space"] {
let mut keymap = TuiKeymap::default();
keymap.chat.previous_permission_mode = Some(one(binding));
assert!(
RuntimeKeymap::from_config(&keymap)
.expect_err("permission shortcuts must not intercept typing")
.contains("printable keys")
);
keymap.chat.previous_permission_mode = None;
keymap.chat.next_permission_mode = Some(one(binding));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
}
#[cfg(windows)]
{
let mut keymap = TuiKeymap::default();
keymap.chat.next_permission_mode = Some(one("ctrl-alt-q"));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
}
let mut keymap = TuiKeymap::default();
keymap.chat.previous_permission_mode = Some(one("f7"));
keymap.chat.next_permission_mode = Some(one("ctrl-x enter"));
assert!(RuntimeKeymap::from_config(&keymap).is_ok());
}
#[test]
fn defaults_include_reassignable_main_surface_actions() {
let runtime = RuntimeKeymap::defaults();
+2
View File
@@ -239,6 +239,8 @@ define_runtime_action_bindings! {
interrupt_turn,
decrease_reasoning_effort,
increase_reasoning_effort,
previous_permission_mode,
next_permission_mode,
edit_queued_message,
],
"composer" => Composer, composer, composer [
+18 -8
View File
@@ -1013,7 +1013,7 @@ mod tests {
let selectable = tab.items.iter().filter(|item| !item.is_disabled).count();
format!("tab: {} ({selectable} selectable)", tab.label)
})
.chain(all_tab.items.iter().take(12).map(|item| {
.chain(all_tab.items.iter().take(13).map(|item| {
format!(
"{} | {} | {}",
item.name,
@@ -1059,13 +1059,23 @@ mod tests {
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
let unbound_tab = selection_tab(&params, KEYMAP_UNBOUND_TAB_ID);
assert_eq!(unbound_tab.items.len(), 2);
assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode");
assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound"));
assert!(!unbound_tab.items[0].is_disabled);
assert_eq!(unbound_tab.items[1].name, "Kill Whole Line");
assert_eq!(unbound_tab.items[1].description.as_deref(), Some("unbound"));
assert!(!unbound_tab.items[1].is_disabled);
assert_eq!(
unbound_tab
.items
.iter()
.map(|item| (
item.name.as_str(),
item.description.as_deref(),
item.is_disabled
))
.collect::<Vec<_>>(),
vec![
("Toggle Vim Mode", Some("unbound"), false),
("Previous Permission Mode", Some("unbound"), false),
("Next Permission Mode", Some("unbound"), false),
("Kill Whole Line", Some("unbound"), false),
]
);
}
#[test]
+4
View File
@@ -96,6 +96,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."),
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
action("chat", "Chat", "previous_permission_mode", "Switch to the previous available permission mode."),
action("chat", "Chat", "next_permission_mode", "Switch to the next available permission mode."),
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
action("composer", "Composer", "submit", "Submit the current composer draft."),
action("composer", "Composer", "queue", "Queue the draft while a task is running."),
@@ -247,6 +249,8 @@ pub(super) fn binding_slot<'a>(
("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn),
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
("chat", "previous_permission_mode") => Some(&mut keymap.chat.previous_permission_mode),
("chat", "next_permission_mode") => Some(&mut keymap.chat.next_permission_mode),
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
("composer", "submit") => Some(&mut keymap.composer.submit),
("composer", "queue") => Some(&mut keymap.composer.queue),
@@ -13,4 +13,4 @@ Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
116 actions, 1 customized, 2 unbound.
118 actions, 1 customized, 4 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
[All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug
Type to search shortcuts
› Global Open Agents alt-a
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
117 actions, 0 customized, 3 unbound.
119 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Agents Approval Debug
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
Type to search shortcuts
› Global Open Agents alt-a
@@ -2,11 +2,11 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (116 selectable)
tab: All (118 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (2) (2 selectable)
tab: App (12 selectable)
tab: Unbound (4) (4 selectable)
tab: App (14 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (49 selectable)
@@ -25,4 +25,5 @@ Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default
Next Permission Mode | unbound | Chat next_permission_mode Next Permission Mode Switch to the next available permission mode. unbound Default
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
116 actions, 1 customized, 2 unbound.
118 actions, 1 customized, 4 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
[All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug
Type to search shortcuts
› Global Open Agents alt-a
@@ -5,9 +5,9 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
116 actions, 0 customized, 2 unbound.
118 actions, 0 customized, 4 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
[All] Common Customized (0) Unbound (4) App Composer Editor Vim
Navigation Agents Approval Debug
Type to search shortcuts
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
116 actions, 0 customized, 2 unbound.
118 actions, 0 customized, 4 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
[All] Common Customized (0) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug
Type to search shortcuts
› Global Open Agents alt-a