Skip to content
Merged
479 changes: 319 additions & 160 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs

Large diffs are not rendered by default.

958 changes: 958 additions & 0 deletions codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs

Large diffs are not rendered by default.

929 changes: 911 additions & 18 deletions codex-rs/tui/src/bottom_pane/chat_composer_history.rs

Large diffs are not rendered by default.

39 changes: 31 additions & 8 deletions codex-rs/tui/src/bottom_pane/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ impl CollaborationModeIndicator {
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
/// Single-line incremental history search prompt shown while Ctrl+R search is active.
HistorySearch,
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
QuitShortcutReminder,
/// Multi-line shortcut overlay shown after pressing `?`.
Expand Down Expand Up @@ -179,6 +181,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
FooterMode::EscHint
| FooterMode::ShortcutOverlay
| FooterMode::QuitShortcutReminder
| FooterMode::HistorySearch
| FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty,
other => other,
}
Expand All @@ -188,13 +191,15 @@ pub(crate) fn footer_height(props: &FooterProps) -> u16 {
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
false
}
FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::HistorySearch
| FooterMode::ComposerEmpty
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
Expand Down Expand Up @@ -593,6 +598,7 @@ fn footer_from_props_lines(
FooterMode::QuitShortcutReminder => {
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
}
FooterMode::HistorySearch => vec![Line::from("reverse-i-search: ").dim()],
FooterMode::ComposerEmpty => {
let state = LeftSideState {
hint: if show_shortcuts_hint {
Expand Down Expand Up @@ -666,9 +672,10 @@ pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> bool {
match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !props.is_task_running,
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
false
}
FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
}
}

Expand Down Expand Up @@ -756,6 +763,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut paste_image = Line::from("");
let mut external_editor = Line::from("");
let mut edit_previous = Line::from("");
let mut history_search = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
let mut change_mode = Line::from("");
Expand All @@ -771,6 +779,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::PasteImage => paste_image = text,
ShortcutId::ExternalEditor => external_editor = text,
ShortcutId::EditPrevious => edit_previous = text,
ShortcutId::HistorySearch => history_search = text,
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
ShortcutId::ChangeMode => change_mode = text,
Expand All @@ -787,6 +796,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
paste_image,
external_editor,
edit_previous,
history_search,
quit,
];
if change_mode.width() > 0 {
Expand Down Expand Up @@ -869,6 +879,7 @@ enum ShortcutId {
PasteImage,
ExternalEditor,
EditPrevious,
HistorySearch,
Quit,
ShowTranscript,
ChangeMode,
Expand Down Expand Up @@ -1027,6 +1038,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: "",
},
ShortcutDescriptor {
id: ShortcutId::HistorySearch,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('r')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " search history",
},
ShortcutDescriptor {
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
Expand Down Expand Up @@ -1086,13 +1106,15 @@ mod tests {
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder
FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running,
FooterMode::QuitShortcutReminder
FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ComposerEmpty
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
Expand Down Expand Up @@ -1227,6 +1249,7 @@ mod tests {
&& !matches!(
props.mode,
FooterMode::EscHint
| FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
);
Expand Down
37 changes: 33 additions & 4 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
//! shortcut.
//! view the first chance to consume Ctrl+C (typically to dismiss itself), then lets an active
//! composer history search consume Ctrl+C as cancellation, and `ChatWidget` may treat an unhandled
//! Ctrl+C as an interrupt or as the first press of a double-press quit shortcut.
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
Expand Down Expand Up @@ -458,7 +458,8 @@ impl BottomPane {
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
/// itself). If no view is active, Ctrl+C clears draft composer input.
/// itself). If no view is active, Ctrl+C cancels active history search before falling back to
/// clearing draft composer input.
///
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
Expand All @@ -475,6 +476,9 @@ impl BottomPane {
self.request_redraw();
}
event
} else if self.composer.cancel_history_search() {
self.request_redraw();
CancellationEvent::Handled
} else if self.composer_is_empty() {
CancellationEvent::NotHandled
} else {
Expand Down Expand Up @@ -1305,6 +1309,31 @@ mod tests {
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
}

#[test]
fn ctrl_c_cancels_history_search_without_clearing_draft_or_showing_quit_hint() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: true,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.insert_str("draft");

pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(pane.composer.popup_active());

assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert_eq!(pane.composer_text(), "draft");
assert!(!pane.composer.popup_active());
assert!(!pane.quit_shortcut_hint_visible());
}

// live ring removed; related tests deleted.

#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› cargo test "
" "
" "
" "
" "
" "
" "
" reverse-i-search: c enter accept · esc cancel "
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ expression: terminal.backend()
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" ctrl + r search history ctrl + c to exit "
" ctrl + t to view transcript "
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ expression: terminal.backend()
" ctrl + j for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + c to exit shift + tab to change mode "
" ctrl + t to view transcript "
" ctrl + r search history ctrl + c to exit "
" shift + tab to change mode "
" ctrl + t to view transcript "
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ expression: terminal.backend()
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" ctrl + r search history ctrl + c to exit "
" ctrl + t to view transcript "
78 changes: 75 additions & 3 deletions codex-rs/tui/src/bottom_pane/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1366,7 +1366,7 @@ impl TextArea {
impl WidgetRef for &TextArea {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let lines = self.wrapped_lines(area.width);
self.render_lines(area, buf, &lines, 0..lines.len(), Style::default());
self.render_lines(area, buf, &lines, 0..lines.len(), Style::default(), &[]);
}
}

Expand All @@ -1380,7 +1380,7 @@ impl StatefulWidgetRef for &TextArea {

let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines(area, buf, &lines, start..end, Style::default());
self.render_lines(area, buf, &lines, start..end, Style::default(), &[]);
}
}

Expand Down Expand Up @@ -1417,7 +1417,28 @@ impl TextArea {

let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines(area, buf, &lines, start..end, base_style);
self.render_lines(area, buf, &lines, start..end, base_style, &[]);
}

/// Render the textarea with `base_style` plus additional render-only highlight ranges.
///
/// Highlight ranges are byte ranges in `self.text`. They affect only the buffer rendering and
/// do not mutate the editable text, cursor, element metadata, or wrapping cache.
pub(crate) fn render_ref_styled_with_highlights(
&self,
area: Rect,
buf: &mut Buffer,
state: &mut TextAreaState,
base_style: Style,
highlights: &[(Range<usize>, Style)],
) {
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
state.scroll = scroll;

let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines(area, buf, &lines, start..end, base_style, highlights);
}

fn render_lines(
Expand All @@ -1427,6 +1448,7 @@ impl TextArea {
lines: &[Range<usize>],
range: std::ops::Range<usize>,
base_style: Style,
highlights: &[(Range<usize>, Style)],
) {
for (row, idx) in range.enumerate() {
let r = &lines[idx];
Expand All @@ -1449,6 +1471,19 @@ impl TextArea {
let style = base_style.fg(ratatui::style::Color::Cyan);
buf.set_string(area.x + x_off, y, styled, style);
}

// Overlay render-only highlight ranges last so transient search highlighting remains
// visible even when it intersects attachment placeholders or other styled elements.
for (highlight_range, style) in highlights {
let overlap_start = highlight_range.start.max(line_range.start);
let overlap_end = highlight_range.end.min(line_range.end);
if overlap_start >= overlap_end {
continue;
}
let highlighted = &self.text[overlap_start..overlap_end];
let x_off = self.text[line_range.start..overlap_start].width() as u16;
buf.set_string(area.x + x_off, y, highlighted, *style);
}
}
}

Expand Down Expand Up @@ -2187,6 +2222,43 @@ mod tests {
assert!(state.scroll < effective_lines);
}

#[test]
fn render_highlights_apply_style_without_mutating_text() {
let t = ta_with("hello world");
let area = Rect::new(0, 0, 20, 1);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
let highlight_style = Style::default().add_modifier(ratatui::style::Modifier::REVERSED);

t.render_ref_styled_with_highlights(
area,
&mut buf,
&mut state,
Style::default(),
&[(6..11, highlight_style)],
);

assert_eq!(t.text(), "hello world");
assert!(
!buf[(0, 0)]
.style()
.add_modifier
.contains(ratatui::style::Modifier::REVERSED)
);
assert!(
buf[(6, 0)]
.style()
.add_modifier
.contains(ratatui::style::Modifier::REVERSED)
);
assert!(
buf[(10, 0)]
.style()
.add_modifier
.contains(ratatui::style::Modifier::REVERSED)
);
}

#[test]
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
Expand Down
8 changes: 8 additions & 0 deletions docs/tui-chat-composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ Up/Down recall is handled by `ChatComposerHistory` and merges two sources:
This distinction keeps the on-disk history backward compatible and avoids persisting attachments,
while still providing a richer recall experience for in-session edits.

### Reverse history search (Ctrl+R)

Ctrl+R enters an incremental reverse search mode without immediately previewing the latest history entry. While search is active, the footer line becomes the editable query field and the composer body is only a preview of the currently matched entry. `Enter` accepts the preview as a normal editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before search started.

The composer owns the search session because it controls draft snapshots, footer rendering, cursor placement, and preview highlighting. `ChatComposerHistory` owns traversal: it scans persistent and local entries in one offset space, skips duplicate prompt text within a search session, keeps boundary hits on the current match, and resumes scans after asynchronous persistent history responses.

The search query and composer text intentionally remain separate. A no-match result restores the original draft while leaving the footer query open for more typing, and accepting a match clears the search session so highlight styling disappears from the now-editable composer text.

## Config gating for reuse

`ChatComposer` now supports feature gating via `ChatComposerConfig`
Expand Down
Loading