Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codex-rs/config/src/tui_keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ pub struct TuiGlobalKeymap {
pub toggle_shortcuts: Option<KeybindingsSpec>,
/// Toggle Vim mode for the composer input.
pub toggle_vim_mode: Option<KeybindingsSpec>,
/// Toggle Fast mode.
pub toggle_fast_mode: Option<KeybindingsSpec>,
}

/// Chat context keybindings.
Expand Down Expand Up @@ -169,6 +171,8 @@ pub struct TuiEditorKeymap {
pub delete_forward_word: Option<KeybindingsSpec>,
/// Kill text from cursor to line start.
pub kill_line_start: Option<KeybindingsSpec>,
/// Kill the current line.
pub kill_whole_line: Option<KeybindingsSpec>,
/// Kill text from cursor to line end.
pub kill_line_end: Option<KeybindingsSpec>,
/// Yank the kill buffer.
Expand Down
20 changes: 20 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2441,6 +2441,7 @@
"insert_newline": null,
"kill_line_end": null,
"kill_line_start": null,
"kill_whole_line": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
Expand All @@ -2458,6 +2459,7 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
Expand Down Expand Up @@ -2811,6 +2813,14 @@
],
"description": "Kill text from cursor to line start."
},
"kill_whole_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Kill the current line."
},
"move_down": {
"allOf": [
{
Expand Down Expand Up @@ -2938,6 +2948,14 @@
],
"description": "Submit the current composer draft."
},
"toggle_fast_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle Fast mode."
},
"toggle_shortcuts": {
"allOf": [
{
Expand Down Expand Up @@ -3018,6 +3036,7 @@
"insert_newline": null,
"kill_line_end": null,
"kill_line_start": null,
"kill_whole_line": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
Expand All @@ -3042,6 +3061,7 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ impl App {
return;
}

if app_keymap_shortcuts_available
&& self.keymap.app.toggle_fast_mode.is_pressed(key_event)
&& self.chat_widget.can_toggle_fast_mode_from_keybinding()
{
self.chat_widget.toggle_fast_mode_from_ui();
return;
}

if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
Expand Down
64 changes: 62 additions & 2 deletions codex-rs/tui/src/bottom_pane/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,10 @@ impl TextArea {
self.kill_to_beginning_of_line();
return;
}
if keymap.kill_whole_line.is_pressed(event) {
self.kill_current_line();
return;
}
if keymap.kill_line_end.is_pressed(event) {
self.kill_to_end_of_line();
return;
Expand Down Expand Up @@ -780,7 +784,7 @@ impl TextArea {

fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) {
self.delete_current_line();
self.kill_current_line();
return true;
}
if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) {
Expand Down Expand Up @@ -1116,7 +1120,7 @@ impl TextArea {
self.yank_line_range(range);
}

fn delete_current_line(&mut self) {
fn kill_current_line(&mut self) {
let range = self.current_line_range_with_newline();
self.kill_line_range(range);
}
Expand Down Expand Up @@ -2447,6 +2451,51 @@ mod tests {
assert_eq!(t.cursor(), 3);
}

#[test]
fn kill_current_line_removes_current_line_linewise() {
let mut t = ta_with("abc\ndef\nghi");
t.set_cursor(/*pos*/ 5);

t.kill_current_line();

assert_eq!(t.text(), "abc\nghi");
assert_eq!(t.cursor(), 4);
assert_eq!(t.kill_buffer, "def\n");
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
}

#[test]
fn kill_current_line_keeps_previous_newline_for_final_line() {
let mut t = ta_with("abc\ndef");
t.set_cursor(/*pos*/ 5);

t.kill_current_line();

assert_eq!(t.text(), "abc\n");
assert_eq!(t.cursor(), 4);
assert_eq!(t.kill_buffer, "def");
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
}

#[test]
fn kill_whole_line_keymap_dispatch_uses_linewise_kill() {
let mut t = ta_with("abc\ndef\nghi");
t.set_cursor(/*pos*/ 5);
let mut keymap = RuntimeKeymap::defaults().editor;
keymap.kill_line_start.clear();
keymap.kill_whole_line = vec![key_hint::ctrl(KeyCode::Char('u'))];

t.input_with_keymap(
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
&keymap,
);

assert_eq!(t.text(), "abc\nghi");
assert_eq!(t.cursor(), 4);
assert_eq!(t.kill_buffer, "def\n");
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
}

#[test]
fn delete_forward_word_variants() {
let mut t = ta_with("hello world ");
Expand Down Expand Up @@ -2668,6 +2717,17 @@ mod tests {
assert_eq!(t.cursor(), 2);
}

#[test]
fn c0_line_feed_inserts_newline_through_insert_newline_keymap() {
let mut t = ta_with("ab");
t.set_cursor(/*pos*/ 1);

t.input(KeyEvent::new(KeyCode::Char('\u{000a}'), KeyModifiers::NONE));

assert_eq!(t.text(), "a\nb");
assert_eq!(t.cursor(), 2);
}

#[test]
fn c0_control_chars_respect_unbound_editor_movement() {
let mut t = ta_with("a\nb");
Expand Down
15 changes: 15 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9267,6 +9267,12 @@ impl ChatWidget {
self.config.features.enabled(Feature::FastMode)
}

pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool {
self.fast_mode_enabled()
&& !self.is_user_turn_pending_or_running()
&& self.bottom_pane.no_modal_or_popup_active()
}

pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
Expand Down Expand Up @@ -9321,6 +9327,15 @@ impl ChatWidget {
.send(AppEvent::PersistServiceTierSelection { service_tier });
}

pub(crate) fn toggle_fast_mode_from_ui(&mut self) {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}

pub(crate) fn current_model(&self) -> &str {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.model();
Expand Down
15 changes: 12 additions & 3 deletions codex-rs/tui/src/chatwidget/keymap_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ impl ChatWidget {
pub(crate) fn open_keymap_picker(&mut self) {
match RuntimeKeymap::from_config(&self.config.tui_keymap) {
Ok(runtime_keymap) => {
let params = keymap_setup::build_keymap_picker_params(
let params = keymap_setup::build_keymap_picker_params_with_filter(
&runtime_keymap,
&self.config.tui_keymap,
self.keymap_action_filter(),
);
self.bottom_pane.show_selection_view(params);
}
Expand Down Expand Up @@ -120,9 +121,10 @@ impl ChatWidget {
action: &str,
runtime_keymap: &RuntimeKeymap,
) {
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter(
runtime_keymap,
&self.config.tui_keymap,
self.keymap_action_filter(),
context,
action,
);
Expand All @@ -135,9 +137,10 @@ impl ChatWidget {
params,
);
if !replaced {
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter(
runtime_keymap,
&self.config.tui_keymap,
self.keymap_action_filter(),
context,
action,
);
Expand All @@ -146,6 +149,12 @@ impl ChatWidget {
self.request_redraw();
}

fn keymap_action_filter(&self) -> keymap_setup::KeymapActionFilter {
keymap_setup::KeymapActionFilter {
fast_mode_enabled: self.fast_mode_enabled(),
}
}

/// Applies a committed keymap edit to the live chat widget.
///
/// The caller is responsible for persisting the config file before invoking this method. This
Expand Down
7 changes: 1 addition & 6 deletions codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,7 @@ impl ChatWidget {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
self.toggle_fast_mode_from_ui();
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
Expand Down
45 changes: 45 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,51 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}

#[tokio::test]
async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);

chat.toggle_fast_mode_from_ui();

let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
..
})
)),
"expected fast-mode override app event; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection {
service_tier: Some(ServiceTier::Fast),
}
)),
"expected fast-mode persistence app event; events: {events:?}"
);

assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}

#[tokio::test]
async fn fast_keybinding_toggle_requires_feature_and_idle_surface() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false);

assert!(!chat.can_toggle_fast_mode_from_keybinding());

chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
assert!(chat.can_toggle_fast_mode_from_keybinding());

chat.bottom_pane.set_task_running(/*running*/ true);
assert!(!chat.can_toggle_fast_mode_from_keybinding());
}

#[tokio::test]
async fn user_turn_carries_service_tier_after_fast_toggle() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
Expand Down
Loading
Loading