mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
369 lines
13 KiB
Rust
369 lines
13 KiB
Rust
use crossterm::event::KeyCode;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Paragraph;
|
|
|
|
use crate::key_hint;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::adaptive_wrap_lines;
|
|
|
|
/// Widget that displays pending steers plus follow-up inputs held while a turn is in progress.
|
|
///
|
|
/// The widget renders pending steers first, then rejected steers that will be
|
|
/// resubmitted at end of turn, then ordinary queued user messages. Pending
|
|
/// steers explain that they will be submitted after the next tool/result
|
|
/// boundary unless the user presses Esc to interrupt and send them
|
|
/// immediately. The edit hint at the bottom only appears when there are actual
|
|
/// queued user inputs to pop back into the composer. Because some terminals
|
|
/// intercept certain modifier-key combinations, the displayed binding is
|
|
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
|
|
pub(crate) struct PendingInputPreview {
|
|
pub pending_steers: Vec<String>,
|
|
pub rejected_steers: Vec<String>,
|
|
pub queued_messages: Vec<String>,
|
|
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
|
/// be overridden for terminals where that chord is unavailable.
|
|
edit_binding: Option<key_hint::KeyBinding>,
|
|
}
|
|
|
|
const PREVIEW_LINE_LIMIT: usize = 3;
|
|
|
|
impl PendingInputPreview {
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
pending_steers: Vec::new(),
|
|
rejected_steers: Vec::new(),
|
|
queued_messages: Vec::new(),
|
|
edit_binding: Some(key_hint::alt(KeyCode::Up)),
|
|
}
|
|
}
|
|
|
|
/// Replace the keybinding shown in the hint line at the bottom of the
|
|
/// queued-messages list. The caller is responsible for also wiring the
|
|
/// corresponding key event handler.
|
|
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
|
|
self.edit_binding = binding;
|
|
}
|
|
|
|
fn push_truncated_preview_lines(
|
|
lines: &mut Vec<Line<'static>>,
|
|
wrapped: Vec<Line<'static>>,
|
|
overflow_line: Line<'static>,
|
|
) {
|
|
let wrapped_len = wrapped.len();
|
|
lines.extend(wrapped.into_iter().take(PREVIEW_LINE_LIMIT));
|
|
if wrapped_len > PREVIEW_LINE_LIMIT {
|
|
lines.push(overflow_line);
|
|
}
|
|
}
|
|
|
|
fn push_section_header(lines: &mut Vec<Line<'static>>, width: u16, header: Line<'static>) {
|
|
let mut spans = vec!["• ".dim()];
|
|
spans.extend(header.spans);
|
|
lines.extend(adaptive_wrap_lines(
|
|
std::iter::once(Line::from(spans)),
|
|
RtOptions::new(width as usize).subsequent_indent(Line::from(" ".dim())),
|
|
));
|
|
}
|
|
|
|
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
|
if (self.pending_steers.is_empty()
|
|
&& self.rejected_steers.is_empty()
|
|
&& self.queued_messages.is_empty())
|
|
|| width < 4
|
|
{
|
|
return Box::new(());
|
|
}
|
|
|
|
let mut lines = vec![];
|
|
|
|
if !self.pending_steers.is_empty() {
|
|
Self::push_section_header(
|
|
&mut lines,
|
|
width,
|
|
Line::from(vec![
|
|
"Messages to be submitted after next tool call".into(),
|
|
" (press ".dim(),
|
|
key_hint::plain(KeyCode::Esc).into(),
|
|
" to interrupt and send immediately)".dim(),
|
|
]),
|
|
);
|
|
|
|
for steer in &self.pending_steers {
|
|
let wrapped = adaptive_wrap_lines(
|
|
steer.lines().map(|line| Line::from(line.dim())),
|
|
RtOptions::new(width as usize)
|
|
.initial_indent(Line::from(" ↳ ".dim()))
|
|
.subsequent_indent(Line::from(" ")),
|
|
);
|
|
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim()));
|
|
}
|
|
}
|
|
|
|
if !self.rejected_steers.is_empty() {
|
|
if !lines.is_empty() {
|
|
lines.push(Line::from(""));
|
|
}
|
|
Self::push_section_header(
|
|
&mut lines,
|
|
width,
|
|
"Messages to be submitted at end of turn".into(),
|
|
);
|
|
|
|
for steer in &self.rejected_steers {
|
|
let wrapped = adaptive_wrap_lines(
|
|
steer.lines().map(|line| Line::from(line.dim())),
|
|
RtOptions::new(width as usize)
|
|
.initial_indent(Line::from(" ↳ ".dim()))
|
|
.subsequent_indent(Line::from(" ")),
|
|
);
|
|
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim()));
|
|
}
|
|
}
|
|
|
|
if !self.queued_messages.is_empty() {
|
|
if !lines.is_empty() {
|
|
lines.push(Line::from(""));
|
|
}
|
|
Self::push_section_header(&mut lines, width, "Queued follow-up inputs".into());
|
|
|
|
for message in &self.queued_messages {
|
|
let wrapped = adaptive_wrap_lines(
|
|
message.lines().map(|line| Line::from(line.dim().italic())),
|
|
RtOptions::new(width as usize)
|
|
.initial_indent(Line::from(" ↳ ".dim()))
|
|
.subsequent_indent(Line::from(" ")),
|
|
);
|
|
Self::push_truncated_preview_lines(
|
|
&mut lines,
|
|
wrapped,
|
|
Line::from(" …".dim().italic()),
|
|
);
|
|
}
|
|
}
|
|
|
|
if !self.queued_messages.is_empty()
|
|
&& let Some(edit_binding) = self.edit_binding
|
|
{
|
|
lines.push(
|
|
Line::from(vec![
|
|
" ".into(),
|
|
edit_binding.into(),
|
|
" edit last queued message".into(),
|
|
])
|
|
.dim(),
|
|
);
|
|
}
|
|
|
|
Paragraph::new(lines).into()
|
|
}
|
|
}
|
|
|
|
impl Renderable for PendingInputPreview {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.is_empty() {
|
|
return;
|
|
}
|
|
|
|
self.as_renderable(area.width).render(area, buf);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
self.as_renderable(width).desired_height(width)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn desired_height_empty() {
|
|
let queue = PendingInputPreview::new();
|
|
assert_eq!(queue.desired_height(/*width*/ 40), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn desired_height_one_message() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.queued_messages.push("Hello, world!".to_string());
|
|
assert_eq!(queue.desired_height(/*width*/ 40), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn render_one_message() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.queued_messages.push("Hello, world!".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_one_message", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_one_message_with_shift_left_binding() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.queued_messages.push("Hello, world!".to_string());
|
|
queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left)));
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!(
|
|
"render_one_message_with_shift_left_binding",
|
|
format!("{buf:?}")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_two_messages() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.queued_messages.push("Hello, world!".to_string());
|
|
queue
|
|
.queued_messages
|
|
.push("This is another message".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_two_messages", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_more_than_three_messages() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.queued_messages.push("Hello, world!".to_string());
|
|
queue
|
|
.queued_messages
|
|
.push("This is another message".to_string());
|
|
queue
|
|
.queued_messages
|
|
.push("This is a third message".to_string());
|
|
queue
|
|
.queued_messages
|
|
.push("This is a fourth message".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_more_than_three_messages", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_wrapped_message() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue
|
|
.queued_messages
|
|
.push("This is a longer message that should be wrapped".to_string());
|
|
queue
|
|
.queued_messages
|
|
.push("This is another message".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_wrapped_message", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_many_line_message() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue
|
|
.queued_messages
|
|
.push("This is\na message\nwith many\nlines".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_many_line_message", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.queued_messages.push(
|
|
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789"
|
|
.to_string(),
|
|
);
|
|
|
|
let width = 36;
|
|
let height = queue.desired_height(width);
|
|
assert_eq!(
|
|
height, 3,
|
|
"expected header, one message row, and hint row for URL-like token"
|
|
);
|
|
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
|
|
let rendered_rows = (0..height)
|
|
.map(|y| {
|
|
(0..width)
|
|
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
!rendered_rows.iter().any(|row| row.contains('…')),
|
|
"expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_one_pending_steer() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.pending_steers.push("Please continue.".to_string());
|
|
let width = 48;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_one_pending_steer", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_pending_steers_above_queued_messages() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.pending_steers.push("Please continue.".to_string());
|
|
queue
|
|
.pending_steers
|
|
.push("Check the last command output.".to_string());
|
|
queue
|
|
.rejected_steers
|
|
.push("Rejected steer that will be retried.".to_string());
|
|
queue
|
|
.queued_messages
|
|
.push("Queued follow-up question".to_string());
|
|
let width = 52;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!(
|
|
"render_pending_steers_above_queued_messages",
|
|
format!("{buf:?}")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_multiline_pending_steer_uses_single_prefix_and_truncates() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue
|
|
.pending_steers
|
|
.push("First line\nSecond line\nThird line\nFourth line".to_string());
|
|
let width = 48;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!(
|
|
"render_multiline_pending_steer_uses_single_prefix_and_truncates",
|
|
format!("{buf:?}")
|
|
);
|
|
}
|
|
}
|