Merge pull request #2471 from Hmbown/codex/composer-input-fix

fix(tui): keep composer scroll and alt motion in draft
This commit is contained in:
Hunter Bown
2026-05-31 16:36:26 -07:00
committed by GitHub
4 changed files with 202 additions and 1 deletions
+20
View File
@@ -86,6 +86,26 @@ pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool {
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT)
}
pub(crate) fn handle_composer_alt_word_motion_key(app: &mut App, key: KeyEvent) -> bool {
if !key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
match key.code {
KeyCode::Char('f') | KeyCode::Char('F') => {
app.clear_selection();
app.move_cursor_word_forward();
true
}
KeyCode::Char('b') | KeyCode::Char('B') => {
app.clear_selection();
app.move_cursor_word_backward();
true
}
_ => false,
}
}
pub(crate) fn is_composer_newline_key(key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL),
+76
View File
@@ -25,6 +25,8 @@ use crate::tui::ui::{
open_details_pager_for_cell, open_pager_for_selection,
};
const COMPOSER_MOUSE_SCROLL_LINES: usize = 3;
pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool {
if !app.is_loading {
return false;
@@ -79,6 +81,68 @@ fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option
Some(line_start + char_offset)
}
fn composer_wrapped_cursor_row_col(
input: &str,
cursor: usize,
wrapped: &[(usize, String)],
) -> (usize, usize) {
let total = input.chars().count();
let cursor = cursor.min(total);
for (idx, (line_start, line_text)) in wrapped.iter().enumerate() {
let next_start = wrapped
.get(idx + 1)
.map(|(start, _)| *start)
.unwrap_or_else(|| total.saturating_add(1));
if cursor >= *line_start && cursor < next_start {
let line_len = line_text.chars().count();
return (idx, cursor.saturating_sub(*line_start).min(line_len));
}
}
let row = wrapped.len().saturating_sub(1);
let col = wrapped
.get(row)
.map(|(_, line_text)| line_text.chars().count())
.unwrap_or(0);
(row, col)
}
fn move_composer_cursor_by_wrapped_rows(app: &mut App, inner: Rect, rows: isize) {
if app.input.is_empty() || rows == 0 {
return;
}
let width = inner.width.max(1) as usize;
let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width);
if wrapped.len() <= 1 {
return;
}
let (current_row, current_col) =
composer_wrapped_cursor_row_col(&app.input, app.cursor_position, &wrapped);
let max_row = wrapped.len().saturating_sub(1);
let target_row = if rows.is_negative() {
current_row.saturating_sub(rows.unsigned_abs())
} else {
current_row.saturating_add(rows as usize).min(max_row)
};
if target_row == current_row {
return;
}
let (target_start, target_text) = &wrapped[target_row];
let target_len = target_text.chars().count();
let total = app.input.chars().count();
app.clear_selection();
app.cursor_position = target_start
.saturating_add(current_col.min(target_len))
.min(total);
app.needs_redraw = true;
}
/// Handle mouse events within the composer area.
/// Returns true if the event was consumed.
pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
@@ -97,6 +161,18 @@ pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
let inner = app.viewport.last_composer_content.unwrap_or(area);
match mouse.kind {
MouseEventKind::ScrollUp => {
move_composer_cursor_by_wrapped_rows(
app,
inner,
-(COMPOSER_MOUSE_SCROLL_LINES as isize),
);
true
}
MouseEventKind::ScrollDown => {
move_composer_cursor_by_wrapped_rows(app, inner, COMPOSER_MOUSE_SCROLL_LINES as isize);
true
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) {
app.cursor_position = pos;
+3 -1
View File
@@ -3773,6 +3773,7 @@ async fn run_event_loop(
app.clear_selection();
app.move_cursor_end();
}
_ if handle_composer_alt_word_motion_key(app, key) => {}
KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Ctrl+O: spawn $EDITOR on the composer contents (#91).
// Only fires when no modal is active (the !view_stack
@@ -3945,9 +3946,10 @@ async fn run_event_loop(
{
// absorb — Visual mode not yet fully implemented
}
KeyCode::Char(c) => {
KeyCode::Char(c) if is_plain_char => {
app.insert_char(c);
}
KeyCode::Char(_) => {}
_ => {}
}
+103
View File
@@ -295,6 +295,45 @@ fn word_cursor_modifier_accepts_control_and_alt() {
assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));
}
#[test]
fn alt_f_and_alt_b_move_by_word_without_inserting_text() {
let mut app = create_test_app();
app.input = "alpha beta gamma".to_string();
app.cursor_position = 0;
assert!(handle_composer_alt_word_motion_key(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
));
assert_eq!(app.input, "alpha beta gamma");
assert_eq!(app.cursor_position, "alpha ".chars().count());
app.selection_anchor = Some(0);
assert!(handle_composer_alt_word_motion_key(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
));
assert_eq!(app.input, "alpha beta gamma");
assert_eq!(app.cursor_position, 0);
assert!(app.selection_anchor.is_none());
}
#[test]
fn alt_word_motion_helper_ignores_altgr_style_control_alt() {
let mut app = create_test_app();
app.input = "alpha beta".to_string();
app.cursor_position = 0;
assert!(!handle_composer_alt_word_motion_key(
&mut app,
KeyEvent::new(
KeyCode::Char('f'),
KeyModifiers::CONTROL | KeyModifiers::ALT
),
));
assert_eq!(app.cursor_position, 0);
}
fn select_full_transcript(app: &mut App) {
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
line_index: 0,
@@ -1141,6 +1180,70 @@ fn mouse_events_do_not_mutate_transcript_behind_modal() {
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
}
#[test]
fn composer_mouse_wheel_scrolls_wrapped_draft_not_transcript() {
let mut app = create_test_app();
app.input = "alpha beta gamma delta epsilon".to_string();
app.cursor_position = 0;
app.viewport.last_composer_area = Some(Rect {
x: 0,
y: 10,
width: 12,
height: 5,
});
app.viewport.last_composer_content = Some(Rect {
x: 1,
y: 11,
width: 5,
height: 3,
});
let events = handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 2,
row: 12,
modifiers: KeyModifiers::NONE,
},
);
assert!(events.is_empty());
assert_eq!(app.viewport.pending_scroll_delta, 0);
assert!(app.cursor_position > 0);
}
#[test]
fn composer_mouse_wheel_up_moves_within_wrapped_draft() {
let mut app = create_test_app();
app.input = "alpha beta gamma delta epsilon".to_string();
app.cursor_position = app.input.chars().count();
app.viewport.last_composer_area = Some(Rect {
x: 0,
y: 10,
width: 12,
height: 5,
});
app.viewport.last_composer_content = Some(Rect {
x: 1,
y: 11,
width: 5,
height: 3,
});
assert!(handle_composer_mouse(
&mut app,
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 2,
row: 12,
modifiers: KeyModifiers::NONE,
},
));
assert!(app.cursor_position < app.input.chars().count());
}
#[test]
fn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {
assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(