mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Fixes #26025. ## Why `/goal edit` opens `CustomPromptView`, which did not use the paste-burst handling that protects the main composer when terminals deliver paste as rapid key events. On Windows terminals, the first pasted newline could be treated as Enter-to-submit, truncating the goal edit and leaving the rest of the paste behind. ## What This reuses `PasteBurst` in `CustomPromptView` as a lightweight Enter-suppression detector for paste-like key streams. Characters still insert directly, explicit paste still goes through the view paste path, and ordinary text entry still submits on Enter.
300 lines
9.6 KiB
Rust
300 lines
9.6 KiB
Rust
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyModifiers;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Clear;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::StatefulWidgetRef;
|
|
use ratatui::widgets::Widget;
|
|
use std::cell::RefCell;
|
|
use std::time::Instant;
|
|
|
|
use crate::key_hint::has_ctrl_or_alt;
|
|
use crate::render::renderable::Renderable;
|
|
|
|
use super::popup_consts::standard_popup_hint_line;
|
|
|
|
use super::CancellationEvent;
|
|
use super::bottom_pane_view::BottomPaneView;
|
|
use super::bottom_pane_view::ViewCompletion;
|
|
use super::paste_burst::PasteBurst;
|
|
use super::textarea::TextArea;
|
|
use super::textarea::TextAreaState;
|
|
|
|
/// Callback invoked when the user submits a custom prompt.
|
|
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
|
|
|
|
/// Minimal multi-line text input view to collect custom review instructions.
|
|
pub(crate) struct CustomPromptView {
|
|
title: String,
|
|
placeholder: String,
|
|
context_label: Option<String>,
|
|
on_submit: PromptSubmitted,
|
|
|
|
// UI state
|
|
textarea: TextArea,
|
|
textarea_state: RefCell<TextAreaState>,
|
|
paste_burst: PasteBurst,
|
|
completion: Option<ViewCompletion>,
|
|
}
|
|
|
|
impl CustomPromptView {
|
|
pub(crate) fn new(
|
|
title: String,
|
|
placeholder: String,
|
|
initial_text: String,
|
|
context_label: Option<String>,
|
|
on_submit: PromptSubmitted,
|
|
) -> Self {
|
|
let mut textarea = TextArea::new();
|
|
if !initial_text.is_empty() {
|
|
textarea.set_text_clearing_elements(&initial_text);
|
|
textarea.set_cursor(initial_text.len());
|
|
}
|
|
|
|
Self {
|
|
title,
|
|
placeholder,
|
|
context_label,
|
|
on_submit,
|
|
textarea,
|
|
textarea_state: RefCell::new(TextAreaState::default()),
|
|
paste_burst: PasteBurst::default(),
|
|
completion: None,
|
|
}
|
|
}
|
|
|
|
fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) {
|
|
match key_event {
|
|
KeyEvent {
|
|
code: KeyCode::Esc, ..
|
|
} => {
|
|
self.on_ctrl_c();
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
modifiers,
|
|
..
|
|
} => {
|
|
if self.paste_burst.direct_insert_newline_should_insert(now) {
|
|
self.paste_burst.extend_window(now);
|
|
self.textarea.insert_str("\n");
|
|
return;
|
|
}
|
|
if modifiers == KeyModifiers::NONE {
|
|
let text = self.textarea.text().trim().to_string();
|
|
if !text.is_empty() {
|
|
(self.on_submit)(text);
|
|
self.completion = Some(ViewCompletion::Accepted);
|
|
}
|
|
} else {
|
|
self.textarea.input(key_event);
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Char(_),
|
|
modifiers,
|
|
..
|
|
} if !has_ctrl_or_alt(modifiers) && self.textarea.allows_paste_burst() => {
|
|
let paste_like_burst = self.paste_burst.on_plain_char_no_hold(now).is_some();
|
|
self.textarea.input(key_event);
|
|
if paste_like_burst {
|
|
self.paste_burst.extend_window(now);
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Tab,
|
|
modifiers,
|
|
..
|
|
} if !has_ctrl_or_alt(modifiers) && self.textarea.allows_paste_burst() => {
|
|
let in_paste_burst = self.paste_burst.direct_insert_newline_should_insert(now);
|
|
self.textarea.input(key_event);
|
|
if in_paste_burst {
|
|
self.paste_burst.extend_window(now);
|
|
}
|
|
}
|
|
other => {
|
|
self.textarea.input(other);
|
|
self.paste_burst.clear_after_explicit_paste();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BottomPaneView for CustomPromptView {
|
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
self.handle_key_event_at(key_event, Instant::now());
|
|
}
|
|
|
|
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
|
self.completion = Some(ViewCompletion::Cancelled);
|
|
CancellationEvent::Handled
|
|
}
|
|
|
|
fn is_complete(&self) -> bool {
|
|
self.completion.is_some()
|
|
}
|
|
|
|
fn completion(&self) -> Option<ViewCompletion> {
|
|
self.completion
|
|
}
|
|
|
|
fn handle_paste(&mut self, pasted: String) -> bool {
|
|
if pasted.is_empty() {
|
|
return false;
|
|
}
|
|
self.textarea.insert_str(&pasted);
|
|
self.paste_burst.clear_after_explicit_paste();
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "custom_prompt_view_tests.rs"]
|
|
mod tests;
|
|
|
|
impl Renderable for CustomPromptView {
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
|
1u16 + extra_top + self.input_height(width) + 3u16
|
|
}
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.height == 0 || area.width == 0 {
|
|
return;
|
|
}
|
|
|
|
let input_height = self.input_height(area.width);
|
|
|
|
// Title line
|
|
let title_area = Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
|
|
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
|
|
|
|
// Optional context line
|
|
let mut input_y = area.y.saturating_add(1);
|
|
if let Some(context_label) = &self.context_label {
|
|
let context_area = Rect {
|
|
x: area.x,
|
|
y: input_y,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
|
|
Paragraph::new(Line::from(spans)).render(context_area, buf);
|
|
input_y = input_y.saturating_add(1);
|
|
}
|
|
|
|
// Input line
|
|
let input_area = Rect {
|
|
x: area.x,
|
|
y: input_y,
|
|
width: area.width,
|
|
height: input_height,
|
|
};
|
|
if input_area.width >= 2 {
|
|
for row in 0..input_area.height {
|
|
Paragraph::new(Line::from(vec![gutter()])).render(
|
|
Rect {
|
|
x: input_area.x,
|
|
y: input_area.y.saturating_add(row),
|
|
width: 2,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
}
|
|
|
|
let text_area_height = input_area.height.saturating_sub(1);
|
|
if text_area_height > 0 {
|
|
if input_area.width > 2 {
|
|
let blank_rect = Rect {
|
|
x: input_area.x.saturating_add(2),
|
|
y: input_area.y,
|
|
width: input_area.width.saturating_sub(2),
|
|
height: 1,
|
|
};
|
|
Clear.render(blank_rect, buf);
|
|
}
|
|
let textarea_rect = Rect {
|
|
x: input_area.x.saturating_add(2),
|
|
y: input_area.y.saturating_add(1),
|
|
width: input_area.width.saturating_sub(2),
|
|
height: text_area_height,
|
|
};
|
|
let mut state = self.textarea_state.borrow_mut();
|
|
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
|
if self.textarea.text().is_empty() {
|
|
Paragraph::new(Line::from(self.placeholder.clone().dim()))
|
|
.render(textarea_rect, buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
let hint_blank_y = input_area.y.saturating_add(input_height);
|
|
if hint_blank_y < area.y.saturating_add(area.height) {
|
|
let blank_area = Rect {
|
|
x: area.x,
|
|
y: hint_blank_y,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
Clear.render(blank_area, buf);
|
|
}
|
|
|
|
let hint_y = hint_blank_y.saturating_add(1);
|
|
if hint_y < area.y.saturating_add(area.height) {
|
|
Paragraph::new(standard_popup_hint_line()).render(
|
|
Rect {
|
|
x: area.x,
|
|
y: hint_y,
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
if area.height < 2 || area.width <= 2 {
|
|
return None;
|
|
}
|
|
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
|
if text_area_height == 0 {
|
|
return None;
|
|
}
|
|
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
|
let top_line_count = 1u16 + extra_offset;
|
|
let textarea_rect = Rect {
|
|
x: area.x.saturating_add(2),
|
|
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
|
width: area.width.saturating_sub(2),
|
|
height: text_area_height,
|
|
};
|
|
let state = *self.textarea_state.borrow();
|
|
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
|
}
|
|
}
|
|
|
|
impl CustomPromptView {
|
|
fn input_height(&self, width: u16) -> u16 {
|
|
let usable_width = width.saturating_sub(2);
|
|
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
|
|
text_height.saturating_add(1).min(9)
|
|
}
|
|
}
|
|
|
|
fn gutter() -> Span<'static> {
|
|
"▌ ".cyan()
|
|
}
|