mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
## What changed - Accept two-stroke bindings such as `ctrl-x ctrl-s` in TUI keymap configuration while preserving arrays as alternative bindings. - Route chords through the active TUI context, show pending and configured chord hints, and cancel pending chords on `esc`, context changes, or a one-second timeout. - Extend `/keymap` to capture, add, replace, and display key chords. - Reject chords that conflict with active single-key bindings, overlapping-context chords, or reserved terminal shortcuts. ## Testing - Add coverage for chord parsing, validation, matching, timeout and cancellation behavior, context dispatch, key capture, and rendered shortcut hints. GitOrigin-RevId: dfdede03f518d4936ad8621ebf5537026f2b282e
390 lines
14 KiB
Rust
390 lines
14 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 invokes the interrupt binding to 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::ShortcutHint>,
|
|
/// Key combination rendered for immediately interrupting and sending steers.
|
|
interrupt_binding: Option<key_hint::ShortcutHint>,
|
|
}
|
|
|
|
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).into()),
|
|
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
|
|
}
|
|
}
|
|
|
|
/// 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::ShortcutHint>) {
|
|
self.edit_binding = binding;
|
|
}
|
|
|
|
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::ShortcutHint>) {
|
|
self.interrupt_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() {
|
|
let mut header = vec!["Messages to be submitted after next tool call".into()];
|
|
if let Some(interrupt_binding) = self.interrupt_binding {
|
|
header.extend(vec![
|
|
" (press ".dim(),
|
|
interrupt_binding.into(),
|
|
" to interrupt and send immediately)".dim(),
|
|
]);
|
|
}
|
|
Self::push_section_header(&mut lines, width, Line::from(header));
|
|
|
|
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).into()));
|
|
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_one_pending_steer_with_remapped_interrupt_binding() {
|
|
let mut queue = PendingInputPreview::new();
|
|
queue.pending_steers.push("Please continue.".to_string());
|
|
queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into()));
|
|
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_with_remapped_interrupt_binding",
|
|
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:?}")
|
|
);
|
|
}
|
|
}
|