Jeremy Rose
2025-09-02 10:29:58 -07:00
committed by GitHub
parent 3f8d6021ac
commit e442ecedab
84 changed files with 2896 additions and 2167 deletions

View File

@@ -27,6 +27,7 @@ use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -223,7 +224,7 @@ impl ChatComposer {
let placeholder = format!("[Pasted Content {char_count} chars]");
self.textarea.insert_element(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if self.handle_paste_image_path(pasted.clone()) {
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
self.textarea.insert_str(" ");
} else {
self.textarea.insert_str(&pasted);
@@ -298,12 +299,7 @@ impl ChatComposer {
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
let now = Instant::now();
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
let _ = self.handle_paste(pasted);
return true;
}
false
self.handle_paste_burst_flush(Instant::now())
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
@@ -396,9 +392,11 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Tab, ..
} => {
// Ensure popup filtering/selection reflects the latest composer text
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(sel) = popup.selected_item() {
let first_line = self.textarea.text().lines().next().unwrap_or("");
match sel {
CommandItem::Builtin(cmd) => {
let starts_with_cmd = first_line
@@ -853,15 +851,36 @@ impl ChatComposer {
}
}
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
self.handle_paste(pasted);
true
}
FlushResult::Typed(ch) => {
// Mirror insert_str() behavior so popups stay in sync when a
// pending fast char flushes as normal typed input.
self.textarea.insert_str(ch.to_string().as_str());
// Keep popup sync consistent with key handling: prefer slash popup; only
// sync file popup when slash popup is NOT active.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
}
true
}
FlushResult::None => false,
}
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// If we have a buffered non-bracketed paste burst and enough time has
// elapsed since the last char, flush it before handling a new input.
let now = Instant::now();
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
// Reuse normal paste path (handles large-paste placeholders).
self.handle_paste(pasted);
}
self.handle_paste_burst_flush(now);
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
@@ -1672,6 +1691,66 @@ mod tests {
}
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type "/mo" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.
assert_snapshot!("slash_popup_mo", terminal.backend());
}
#[test]
fn slash_popup_model_first_for_mo_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt selected for '/mo'")
}
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
}
}
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;

View File

@@ -238,6 +238,20 @@ mod tests {
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![

View File

@@ -100,53 +100,47 @@ impl BottomPane {
}
pub fn desired_height(&self, width: u16) -> u16 {
let top_margin = if self.active_view.is_some() { 0 } else { 1 };
// Always reserve one blank row above the pane for visual spacing.
let top_margin = 1;
// Base height depends on whether a modal/overlay is active.
let mut base = if let Some(view) = self.active_view.as_ref() {
view.desired_height(width)
} else {
self.composer.desired_height(width)
let base = match self.active_view.as_ref() {
Some(view) => view.desired_height(width),
None => self.composer.desired_height(width).saturating_add(
self.status
.as_ref()
.map_or(0, |status| status.desired_height(width)),
),
};
// If a status indicator is active and no modal is covering the composer,
// include its height above the composer.
if self.active_view.is_none()
&& let Some(status) = self.status.as_ref()
{
base = base.saturating_add(status.desired_height(width));
}
// Account for bottom padding rows. Top spacing is handled in layout().
base.saturating_add(Self::BOTTOM_PAD_LINES)
.saturating_add(top_margin)
}
fn layout(&self, area: Rect) -> [Rect; 2] {
// Prefer showing the status header when space is extremely tight.
// Drop the top spacer if there is only one row available.
let mut top_margin = if self.active_view.is_some() { 0 } else { 1 };
if area.height <= 1 {
top_margin = 0;
}
let status_height = if self.active_view.is_none() {
if let Some(status) = self.status.as_ref() {
status.desired_height(area.width)
} else {
0
}
// At small heights, bottom pane takes the entire height.
let (top_margin, bottom_margin) = if area.height <= BottomPane::BOTTOM_PAD_LINES + 1 {
(0, 0)
} else {
0
(1, BottomPane::BOTTOM_PAD_LINES)
};
let [_, status, content, _] = Layout::vertical([
Constraint::Max(top_margin),
Constraint::Max(status_height),
Constraint::Min(1),
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
])
.areas(area);
[status, content]
let area = Rect {
x: area.x,
y: area.y + top_margin,
width: area.width,
height: area.height - top_margin - bottom_margin,
};
match self.active_view.as_ref() {
Some(_) => [Rect::ZERO, area],
None => {
let status_height = self
.status
.as_ref()
.map_or(0, |status| status.desired_height(area.width));
Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area)
}
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -705,7 +699,7 @@ mod tests {
pane.set_task_running(true);
// Height=2 → composer visible; status is hidden to preserve composer. Spacer may collapse.
// Height=2 → status on one row, composer on the other.
let area2 = Rect::new(0, 0, 20, 2);
let mut buf2 = Buffer::empty(area2);
(&pane).render_ref(area2, &mut buf2);
@@ -721,8 +715,8 @@ mod tests {
"expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}"
);
assert!(
!row0.contains("Working") && !row1.contains("Working"),
"status header should be hidden when height=2"
row0.contains("Working") || row1.contains("Working"),
"expected status header to be visible at height=2: row0={row0:?}, row1={row1:?}"
);
// Height=1 → no padding; single row is the composer (status hidden).

View File

@@ -35,6 +35,12 @@ pub(crate) struct RetroGrab {
pub grabbed: String,
}
pub(crate) enum FlushResult {
Paste(String),
Typed(char),
None,
}
impl PasteBurst {
/// Recommended delay to wait between simulated keypresses (or before
/// scheduling a UI tick) so that a pending fast keystroke is flushed
@@ -95,24 +101,24 @@ impl PasteBurst {
/// now emit that char as normal typed input.
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);
Some(out)
FlushResult::Paste(out)
} else if timed_out {
// If we were saving a single fast char and no burst followed,
// flush it as normal typed input.
if let Some((ch, _at)) = self.pending_first_char.take() {
Some(ch.to_string())
FlushResult::Typed(ch)
} else {
None
FlushResult::None
}
} else {
None
FlushResult::None
}
}

View File

@@ -0,0 +1,8 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌/mo "
"▌ "
"▌/model choose what model and reasoning effort to use "
"▌/mention mention a file "

View File

@@ -245,7 +245,6 @@ impl TextArea {
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {