mirror of
https://github.com/openai/codex.git
synced 2026-11-11 04:55:10 +00:00
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:
1 parent
44e95c857f
commit
2aaefa32b0
32 files changed
+708
-47
No files matched your search
@@ -1127,6 +1127,19 @@ fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
|
|||||||
{
|
{
|
||||||
ignored_keys.push("features.respect_system_proxy".to_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
|
ignored_keys
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ use tempfile::tempdir;
|
|||||||
|
|
||||||
pub(super) struct TestFileSystem;
|
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]
|
#[tokio::test]
|
||||||
async fn managed_browser_import_denial_survives_user_and_session_config() {
|
async fn managed_browser_import_denial_survives_user_and_session_config() {
|
||||||
let tmp = tempdir().expect("tempdir");
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ pub struct TuiChatKeymap {
|
|||||||
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
|
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
|
||||||
/// Increase the active reasoning effort.
|
/// Increase the active reasoning effort.
|
||||||
pub increase_reasoning_effort: Option<KeybindingsSpec>,
|
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.
|
/// Edit the most recently queued message.
|
||||||
pub edit_queued_message: Option<KeybindingsSpec>,
|
pub edit_queued_message: Option<KeybindingsSpec>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3571,7 +3571,9 @@
|
|||||||
"decrease_reasoning_effort": null,
|
"decrease_reasoning_effort": null,
|
||||||
"edit_queued_message": null,
|
"edit_queued_message": null,
|
||||||
"increase_reasoning_effort": null,
|
"increase_reasoning_effort": null,
|
||||||
"interrupt_turn": null
|
"interrupt_turn": null,
|
||||||
|
"next_permission_mode": null,
|
||||||
|
"previous_permission_mode": null
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"history_search_next": null,
|
"history_search_next": null,
|
||||||
@@ -3967,6 +3969,22 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Interrupt the active turn."
|
"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"
|
"type": "object"
|
||||||
@@ -4309,7 +4327,9 @@
|
|||||||
"decrease_reasoning_effort": null,
|
"decrease_reasoning_effort": null,
|
||||||
"edit_queued_message": null,
|
"edit_queued_message": null,
|
||||||
"increase_reasoning_effort": null,
|
"increase_reasoning_effort": null,
|
||||||
"interrupt_turn": null
|
"interrupt_turn": null,
|
||||||
|
"next_permission_mode": null,
|
||||||
|
"previous_permission_mode": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ mod history_ui;
|
|||||||
mod input;
|
mod input;
|
||||||
mod loaded_threads;
|
mod loaded_threads;
|
||||||
mod pending_interactive_replay;
|
mod pending_interactive_replay;
|
||||||
|
mod permission_shortcuts;
|
||||||
mod pets;
|
mod pets;
|
||||||
mod platform_actions;
|
mod platform_actions;
|
||||||
mod plugin_mentions;
|
mod plugin_mentions;
|
||||||
|
|||||||
@@ -1519,6 +1519,9 @@ impl App {
|
|||||||
profile_selection,
|
profile_selection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
AppEvent::ApplyPermissionShortcut { thread_id, selection } => {
|
||||||
|
self.apply_permission_shortcut(app_server, tui, thread_id, selection).await;
|
||||||
|
}
|
||||||
AppEvent::OpenWorldWritableWarningConfirmation {
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
||||||
preset,
|
preset,
|
||||||
profile_selection,
|
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}"
|
||||||
|
))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ mod key_chords;
|
|||||||
#[path = "tests/mcp_startup.rs"]
|
#[path = "tests/mcp_startup.rs"]
|
||||||
mod mcp_startup;
|
mod mcp_startup;
|
||||||
mod model_catalog;
|
mod model_catalog;
|
||||||
|
#[path = "tests/permission_shortcuts_tests.rs"]
|
||||||
|
mod permission_shortcuts_tests;
|
||||||
mod plugin_catalog;
|
mod plugin_catalog;
|
||||||
mod rate_limits;
|
mod rate_limits;
|
||||||
mod safety_buffering;
|
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(())
|
||||||
|
}
|
||||||
+6
@@ -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)
|
||||||
+6
@@ -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
|
||||||
@@ -948,6 +948,12 @@ pub(crate) enum AppEvent {
|
|||||||
profile_selection: Option<PermissionProfileSelection>,
|
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.
|
/// Open the Windows world-writable directories warning.
|
||||||
/// If `preset` is `Some`, the confirmation will apply the provided
|
/// If `preset` is `Some`, the confirmation will apply the provided
|
||||||
/// approval/sandbox configuration on Continue; if `None`, it performs no
|
/// approval/sandbox configuration on Continue; if `None`, it performs no
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ mod model_popups;
|
|||||||
mod notifications;
|
mod notifications;
|
||||||
use self::notifications::Notification;
|
use self::notifications::Notification;
|
||||||
mod permission_popups;
|
mod permission_popups;
|
||||||
|
mod permission_shortcuts;
|
||||||
mod permissions_menu;
|
mod permissions_menu;
|
||||||
pub(crate) use self::permissions_menu::auto_review_available;
|
pub(crate) use self::permissions_menu::auto_review_available;
|
||||||
pub(crate) use self::permissions_menu::cyber_model_approval_reviewer;
|
pub(crate) use self::permissions_menu::cyber_model_approval_reviewer;
|
||||||
@@ -696,6 +697,7 @@ pub(crate) struct ChatWidget {
|
|||||||
safety_buffering_prompt: Option<UserMessage>,
|
safety_buffering_prompt: Option<UserMessage>,
|
||||||
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
||||||
chat_keymap: ChatKeymap,
|
chat_keymap: ChatKeymap,
|
||||||
|
permission_shortcut_pending: bool,
|
||||||
/// Keybinding to show for popping the most-recently queued message back
|
/// Keybinding to show for popping the most-recently queued message back
|
||||||
/// into the composer. This may differ from the first configured binding
|
/// into the composer. This may differ from the first configured binding
|
||||||
/// when the default set includes a terminal-specific fallback.
|
/// when the default set includes a terminal-specific fallback.
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ impl ChatWidget {
|
|||||||
input_queue: InputQueueState::default(),
|
input_queue: InputQueueState::default(),
|
||||||
safety_buffering_prompt: None,
|
safety_buffering_prompt: None,
|
||||||
chat_keymap,
|
chat_keymap,
|
||||||
|
permission_shortcut_pending: false,
|
||||||
queued_message_edit_hint_binding,
|
queued_message_edit_hint_binding,
|
||||||
show_welcome_banner: is_first_run,
|
show_welcome_banner: is_first_run,
|
||||||
startup_tooltip_override,
|
startup_tooltip_override,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl ChatWidget {
|
|||||||
return;
|
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.bottom_pane.clear_quit_shortcut_hint();
|
||||||
self.quit_shortcut_expires_at = None;
|
self.quit_shortcut_expires_at = None;
|
||||||
self.quit_shortcut_key = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,31 @@ pub(crate) fn cyber_model_approval_reviewer(config: &Config) -> Option<Approvals
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ChatWidget {
|
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) {
|
pub(super) fn open_permission_profiles_popup(&mut self) {
|
||||||
let active_profile_id = self
|
let active_profile_id = self
|
||||||
.config
|
.config
|
||||||
@@ -161,26 +186,7 @@ impl ChatWidget {
|
|||||||
/*return_to_permissions*/ true,
|
/*return_to_permissions*/ true,
|
||||||
),
|
),
|
||||||
dismiss_on_select: true,
|
dismiss_on_select: true,
|
||||||
disabled_reason: self
|
disabled_reason: self.permission_mode_disabled_reason(preset, approval_policy),
|
||||||
.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())
|
|
||||||
}),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ mod history_replay;
|
|||||||
mod mcp_startup;
|
mod mcp_startup;
|
||||||
#[path = "tests/misalignment_policy_tests.rs"]
|
#[path = "tests/misalignment_policy_tests.rs"]
|
||||||
mod misalignment_policy;
|
mod misalignment_policy;
|
||||||
|
#[path = "tests/permission_shortcuts_tests.rs"]
|
||||||
|
mod permission_shortcuts_tests;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod plan_mode;
|
mod plan_mode;
|
||||||
#[path = "tests/plugin_catalog_tests.rs"]
|
#[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)
|
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();
|
let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default();
|
||||||
requirements_with_sources
|
requirements_with_sources
|
||||||
.merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone());
|
.merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone());
|
||||||
|
|||||||
+5
@@ -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.
|
||||||
@@ -109,6 +109,10 @@ pub(crate) struct ChatKeymap {
|
|||||||
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
|
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
|
||||||
/// Increase the active reasoning effort.
|
/// Increase the active reasoning effort.
|
||||||
pub(crate) increase_reasoning_effort: Vec<KeyBinding>,
|
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.
|
/// Edit the most recently queued message.
|
||||||
pub(crate) edit_queued_message: Vec<KeyBinding>,
|
pub(crate) edit_queued_message: Vec<KeyBinding>,
|
||||||
}
|
}
|
||||||
@@ -648,6 +652,13 @@ impl RuntimeKeymap {
|
|||||||
&defaults.chat.increase_reasoning_effort,
|
&defaults.chat.increase_reasoning_effort,
|
||||||
"tui.keymap.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(
|
edit_queued_message: resolve_bindings(
|
||||||
keymap.chat.edit_queued_message.as_ref(),
|
keymap.chat.edit_queued_message.as_ref(),
|
||||||
&defaults.chat.edit_queued_message,
|
&defaults.chat.edit_queued_message,
|
||||||
@@ -1211,6 +1222,8 @@ impl RuntimeKeymap {
|
|||||||
alt(KeyCode::Char('.')),
|
alt(KeyCode::Char('.')),
|
||||||
shift(KeyCode::Up)
|
shift(KeyCode::Up)
|
||||||
],
|
],
|
||||||
|
previous_permission_mode: default_bindings![],
|
||||||
|
next_permission_mode: default_bindings![],
|
||||||
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
|
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
|
||||||
},
|
},
|
||||||
composer: ComposerKeymap {
|
composer: ComposerKeymap {
|
||||||
@@ -1458,6 +1471,23 @@ impl RuntimeKeymap {
|
|||||||
/// 2. Contexts with hard-coded sequence behavior, such as edit-previous
|
/// 2. Contexts with hard-coded sequence behavior, such as edit-previous
|
||||||
/// backtracking, intentionally stay outside this configurable keymap.
|
/// backtracking, intentionally stay outside this configurable keymap.
|
||||||
fn validate_conflicts(&self) -> Result<(), String> {
|
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)]
|
#[cfg(unix)]
|
||||||
if self
|
if self
|
||||||
.app
|
.app
|
||||||
@@ -1510,6 +1540,14 @@ impl RuntimeKeymap {
|
|||||||
"chat.increase_reasoning_effort",
|
"chat.increase_reasoning_effort",
|
||||||
self.chat.increase_reasoning_effort.as_slice(),
|
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",
|
"chat.edit_queued_message",
|
||||||
self.chat.edit_queued_message.as_slice(),
|
self.chat.edit_queued_message.as_slice(),
|
||||||
@@ -1555,6 +1593,14 @@ impl RuntimeKeymap {
|
|||||||
"chat.increase_reasoning_effort",
|
"chat.increase_reasoning_effort",
|
||||||
self.chat.increase_reasoning_effort.as_slice(),
|
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",
|
"chat.edit_queued_message",
|
||||||
self.chat.edit_queued_message.as_slice(),
|
self.chat.edit_queued_message.as_slice(),
|
||||||
@@ -1668,6 +1714,14 @@ impl RuntimeKeymap {
|
|||||||
"chat.increase_reasoning_effort",
|
"chat.increase_reasoning_effort",
|
||||||
self.chat.increase_reasoning_effort.as_slice(),
|
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()),
|
("composer.submit", self.composer.submit.as_slice()),
|
||||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||||
("toggle_fast_mode", self.app.toggle_fast_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'))]);
|
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]
|
#[test]
|
||||||
fn defaults_include_reassignable_main_surface_actions() {
|
fn defaults_include_reassignable_main_surface_actions() {
|
||||||
let runtime = RuntimeKeymap::defaults();
|
let runtime = RuntimeKeymap::defaults();
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ define_runtime_action_bindings! {
|
|||||||
interrupt_turn,
|
interrupt_turn,
|
||||||
decrease_reasoning_effort,
|
decrease_reasoning_effort,
|
||||||
increase_reasoning_effort,
|
increase_reasoning_effort,
|
||||||
|
previous_permission_mode,
|
||||||
|
next_permission_mode,
|
||||||
edit_queued_message,
|
edit_queued_message,
|
||||||
],
|
],
|
||||||
"composer" => Composer, composer, composer [
|
"composer" => Composer, composer, composer [
|
||||||
|
|||||||
@@ -1013,7 +1013,7 @@ mod tests {
|
|||||||
let selectable = tab.items.iter().filter(|item| !item.is_disabled).count();
|
let selectable = tab.items.iter().filter(|item| !item.is_disabled).count();
|
||||||
format!("tab: {} ({selectable} selectable)", tab.label)
|
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!(
|
format!(
|
||||||
"{} | {} | {}",
|
"{} | {} | {}",
|
||||||
item.name,
|
item.name,
|
||||||
@@ -1059,13 +1059,23 @@ mod tests {
|
|||||||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||||||
let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID);
|
let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID);
|
||||||
|
|
||||||
assert_eq!(unbound_tab.items.len(), 2);
|
assert_eq!(
|
||||||
assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode");
|
unbound_tab
|
||||||
assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound"));
|
.items
|
||||||
assert!(!unbound_tab.items[0].is_disabled);
|
.iter()
|
||||||
assert_eq!(unbound_tab.items[1].name, "Kill Whole Line");
|
.map(|item| (
|
||||||
assert_eq!(unbound_tab.items[1].description.as_deref(), Some("unbound"));
|
item.name.as_str(),
|
||||||
assert!(!unbound_tab.items[1].is_disabled);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
|||||||
action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."),
|
action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."),
|
||||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||||
action("chat", "Chat", "increase_reasoning_effort", "Increase 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("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
||||||
action("composer", "Composer", "submit", "Submit the current composer draft."),
|
action("composer", "Composer", "submit", "Submit the current composer draft."),
|
||||||
action("composer", "Composer", "queue", "Queue the draft while a task is running."),
|
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", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn),
|
||||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_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),
|
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||||
("composer", "submit") => Some(&mut keymap.composer.submit),
|
("composer", "submit") => Some(&mut keymap.composer.submit),
|
||||||
("composer", "queue") => Some(&mut keymap.composer.queue),
|
("composer", "queue") => Some(&mut keymap.composer.queue),
|
||||||
|
|||||||
+1
-1
@@ -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
|
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
|
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
|
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
|
||||||
+2
-2
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
Type to search shortcuts
|
||||||
› Global Open Agents alt-a
|
› Global Open Agents alt-a
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
Type to search shortcuts
|
||||||
› Global Open Agents alt-a
|
› Global Open Agents alt-a
|
||||||
|
|||||||
+5
-4
@@ -2,11 +2,11 @@
|
|||||||
source: tui/src/keymap_setup.rs
|
source: tui/src/keymap_setup.rs
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
tab: All (116 selectable)
|
tab: All (118 selectable)
|
||||||
tab: Common (20 selectable)
|
tab: Common (20 selectable)
|
||||||
tab: Customized (0) (0 selectable)
|
tab: Customized (0) (0 selectable)
|
||||||
tab: Unbound (2) (2 selectable)
|
tab: Unbound (4) (4 selectable)
|
||||||
tab: App (12 selectable)
|
tab: App (14 selectable)
|
||||||
tab: Composer (5 selectable)
|
tab: Composer (5 selectable)
|
||||||
tab: Editor (17 selectable)
|
tab: Editor (17 selectable)
|
||||||
tab: Vim (49 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
|
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
|
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
|
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
|
||||||
+2
-2
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
Type to search shortcuts
|
||||||
› Global Open Agents alt-a
|
› Global Open Agents alt-a
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ expression: "render_picker(params, 78)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
Navigation Agents Approval Debug
|
||||||
|
|
||||||
Type to search shortcuts
|
Type to search shortcuts
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
Type to search shortcuts
|
||||||
› Global Open Agents alt-a
|
› Global Open Agents alt-a
|
||||||
|
|||||||
Reference in new issue
Block a user