Merge origin/main into bug-sweep-report

This commit is contained in:
kh.ai
2025-11-12 15:46:43 -08:00
540 changed files with 55304 additions and 16304 deletions

View File

@@ -35,6 +35,9 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::prompt_args::prompt_argument_names;
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style;
@@ -108,7 +111,7 @@ pub(crate) struct ChatComposer {
custom_prompts: Vec<CustomPrompt>,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
context_window_percent: Option<u8>,
context_window_percent: Option<i64>,
}
/// Popup state at most one can be visible at any time.
@@ -158,24 +161,6 @@ impl ChatComposer {
this
}
pub fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -190,18 +175,9 @@ impl ChatComposer {
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::None => Constraint::Max(footer_total_height),
};
let mut area = area;
if area.height > 1 {
area.height -= 1;
area.y += 1;
}
let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
let mut textarea_rect = composer_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
);
textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS);
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1));
[composer_rect, textarea_rect, popup_rect]
}
@@ -213,12 +189,6 @@ impl ChatComposer {
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
/// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
@@ -242,8 +212,7 @@ impl ChatComposer {
let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
return false;
};
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
self.set_text_content(text);
true
}
@@ -316,6 +285,17 @@ impl ChatComposer {
self.sync_file_search_popup();
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;
}
let previous = self.current_text();
self.set_text_content(String::new());
self.history.reset_navigation();
self.history.record_local_submission(&previous);
Some(previous)
}
/// Get the current composer text.
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
@@ -430,6 +410,11 @@ impl ChatComposer {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
@@ -437,6 +422,11 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
@@ -563,6 +553,16 @@ impl ChatComposer {
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
if let KeyEvent {
code: KeyCode::Char(ch),
..
} = input
{
let now = Instant::now();
if self.paste_burst.try_append_char_if_active(ch, now) {
return (InputResult::None, true);
}
}
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
@@ -594,6 +594,11 @@ impl ChatComposer {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
@@ -601,6 +606,11 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
@@ -852,10 +862,12 @@ impl ChatComposer {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
if self.is_empty() {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
@@ -878,6 +890,11 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('p') | KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
if self
.history
@@ -886,11 +903,12 @@ impl ChatComposer {
let replace_text = match key_event.code {
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx),
KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx),
_ => unreachable!(),
};
if let Some(text) = replace_text {
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
self.set_text_content(text);
return (InputResult::None, true);
}
}
@@ -1508,7 +1526,7 @@ impl ChatComposer {
self.placeholder_text = placeholder;
}
pub(crate) fn set_context_window_percent(&mut self, percent: Option<u8>) {
pub(crate) fn set_context_window_percent(&mut self, percent: Option<i64>) {
if self.context_window_percent != percent {
self.context_window_percent = percent;
}
@@ -1524,8 +1542,32 @@ impl ChatComposer {
}
}
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
match &self.active_popup {
ActivePopup::Command(popup) => {
@@ -1574,16 +1616,15 @@ impl WidgetRef for ChatComposer {
}
}
let style = user_message_style();
let mut block_rect = composer_rect;
block_rect.y = composer_rect.y.saturating_sub(1);
block_rect.height = composer_rect.height.saturating_add(1);
Block::default().style(style).render_ref(block_rect, buf);
buf.set_span(
composer_rect.x,
composer_rect.y,
&"".bold(),
composer_rect.width,
);
Block::default().style(style).render_ref(composer_rect, buf);
if !textarea_rect.is_empty() {
buf.set_span(
textarea_rect.x - LIVE_PREFIX_COLS,
textarea_rect.y,
&"".bold(),
textarea_rect.width,
);
}
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
@@ -1675,7 +1716,7 @@ mod tests {
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
composer.render_ref(area, &mut buf);
composer.render(area, &mut buf);
let row_to_string = |y: u16| {
let mut row = String::new();
@@ -1739,7 +1780,7 @@ mod tests {
let height = footer_lines + footer_spacing + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
@@ -1796,6 +1837,57 @@ mod tests {
});
}
#[test]
fn esc_hint_stays_hidden_with_draft_content() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['d']);
assert!(!composer.is_empty());
assert_eq!(composer.current_text(), "d");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(matches!(composer.active_popup, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(!composer.esc_backtrack_hint);
}
#[test]
fn clear_for_ctrl_c_records_cleared_draft() {
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,
);
composer.set_text_content("draft text".to_string());
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
assert!(composer.is_empty());
assert_eq!(
composer.history.navigate_up(&composer.app_event_tx),
Some("draft text".to_string())
);
}
#[test]
fn question_mark_only_toggles_on_first_char() {
use crossterm::event::KeyCode;
@@ -2011,6 +2103,35 @@ mod tests {
}
}
#[test]
fn ascii_prefix_survives_non_ascii_followup() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
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,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "1あ"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn handle_paste_small_inserts_text() {
use crossterm::event::KeyCode;
@@ -2179,7 +2300,7 @@ mod tests {
}
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
insta::assert_snapshot!(name, terminal.backend());
@@ -2205,12 +2326,12 @@ mod tests {
// 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)) {
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.