This commit is contained in:
Ahmed Ibrahim
2026-01-25 21:13:49 -08:00
parent d27f2533a9
commit 868d23f878
11 changed files with 439 additions and 242 deletions
@@ -1,3 +1,5 @@
use std::time::Duration;
use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable;
use codex_protocol::request_user_input::RequestUserInputEvent;
@@ -27,6 +29,22 @@ pub(crate) trait BottomPaneView: Renderable {
false
}
/// Flush a pending paste-burst when due. Return true if the view modified
/// its state and needs a redraw.
fn flush_paste_burst_if_due(&mut self) -> bool {
false
}
/// Return true if the view is currently capturing a paste-burst.
fn is_in_paste_burst(&self) -> bool {
false
}
/// Recommended delay to schedule the next redraw while capturing a burst.
fn recommended_redraw_delay(&self) -> Option<Duration> {
None
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
+263 -109
View File
@@ -7,6 +7,7 @@
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
//! - Supporting textarea-only embedding by disabling popups/footers and rendering the core input.
//!
//! # Key Event Routing
//!
@@ -201,6 +202,7 @@ pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
active_popup: ActivePopup,
popups_enabled: bool,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
quit_shortcut_expires_at: Option<Instant>,
@@ -224,6 +226,8 @@ pub(crate) struct ChatComposer {
// When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool,
custom_prompts: Vec<CustomPrompt>,
footer_enabled: bool,
prompt_inset_enabled: bool,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
footer_flash: Option<FooterFlash>,
@@ -253,6 +257,22 @@ enum ActivePopup {
const FOOTER_SPACING_HEIGHT: u16 = 0;
pub(crate) fn default_chat_composer(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
) -> ChatComposer {
ChatComposer::new(
has_input_focus,
app_event_tx,
enhanced_keys_supported,
placeholder_text,
disable_paste_burst,
)
}
impl ChatComposer {
pub fn new(
has_input_focus: bool,
@@ -267,6 +287,7 @@ impl ChatComposer {
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
active_popup: ActivePopup::None,
popups_enabled: true,
app_event_tx,
history: ChatComposerHistory::new(),
quit_shortcut_expires_at: None,
@@ -286,6 +307,8 @@ impl ChatComposer {
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
footer_enabled: true,
prompt_inset_enabled: true,
footer_mode: FooterMode::ShortcutSummary,
footer_hint_override: None,
footer_flash: None,
@@ -320,6 +343,66 @@ impl ChatComposer {
self.collaboration_modes_enabled = enabled;
}
pub(crate) fn set_popups_enabled(&mut self, enabled: bool) {
self.popups_enabled = enabled;
if !enabled {
self.active_popup = ActivePopup::None;
}
}
pub(crate) fn set_footer_enabled(&mut self, enabled: bool) {
self.footer_enabled = enabled;
}
pub(crate) fn set_prompt_inset_enabled(&mut self, enabled: bool) {
self.prompt_inset_enabled = enabled;
}
pub(crate) fn desired_textarea_height(&self, width: u16) -> u16 {
let cols_with_margin = if self.prompt_inset_enabled {
LIVE_PREFIX_COLS + 3
} else {
4
};
self.textarea
.desired_height(width.saturating_sub(cols_with_margin))
+ 2
}
pub(crate) fn cursor_pos_textarea_only(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
return None;
}
let textarea_rect = if area.height > 2 && area.width > 2 {
area.inset(Insets::tlbr(1, 1, 1, 1))
} else {
area
};
if textarea_rect.is_empty() {
return None;
}
let state = *self.textarea_state.borrow();
let render_rect = self.textarea_render_rect(textarea_rect);
if render_rect.is_empty() {
return None;
}
self.textarea.cursor_pos_with_state(render_rect, state)
}
pub(crate) fn render_textarea_only(&self, area: Rect, buf: &mut Buffer) {
let style = user_message_style();
Block::default().style(style).render_ref(area, buf);
let textarea_rect = if area.height > 2 && area.width > 2 {
area.inset(Insets::tlbr(1, 1, 1, 1))
} else {
area
};
if textarea_rect.is_empty() {
return;
}
self.render_textarea(textarea_rect, buf);
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,
@@ -328,21 +411,29 @@ impl ChatComposer {
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
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;
let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
let footer_total_height = if self.footer_enabled {
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);
footer_hint_height + footer_spacing
} else {
0
};
let popup_constraint = if self.popups_enabled {
match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::Skill(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::None => Constraint::Max(footer_total_height),
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::Skill(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::None => Constraint::Max(footer_total_height),
} else {
Constraint::Max(footer_total_height)
};
let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
@@ -350,6 +441,100 @@ impl ChatComposer {
[composer_rect, textarea_rect, popup_rect]
}
fn render_footer(&self, popup_rect: Rect, buf: &mut Buffer) {
let footer_props = self.footer_props();
let custom_height = self.custom_footer_height();
let footer_hint_height = custom_height.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(footer_spacing),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
let mut left_content_width = None;
if self.footer_flash_visible() {
if let Some(flash) = self.footer_flash.as_ref() {
flash.line.render(inset_footer_hint_area(hint_rect), buf);
left_content_width = Some(flash.line.width() as u16);
}
} else if let Some(items) = self.footer_hint_override.as_ref() {
render_footer_hint_items(hint_rect, buf, items);
left_content_width = Some(footer_hint_items_width(items));
} else {
render_footer(hint_rect, buf, footer_props);
left_content_width = Some(footer_line_width(footer_props));
}
render_mode_indicator(
hint_rect,
buf,
self.collaboration_mode_indicator,
!footer_props.is_task_running,
left_content_width,
);
}
fn textarea_render_rect(&self, textarea_rect: Rect) -> Rect {
let rect = if self.prompt_inset_enabled && textarea_rect.x < LIVE_PREFIX_COLS {
Rect {
x: textarea_rect.x.saturating_add(LIVE_PREFIX_COLS),
width: textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS),
..textarea_rect
}
} else {
textarea_rect
};
if self.prompt_inset_enabled {
rect
} else {
Rect {
x: rect.x.saturating_add(2),
width: rect.width.saturating_sub(2),
..rect
}
}
}
fn render_textarea(&self, textarea_rect: Rect, buf: &mut Buffer) {
let render_rect = self.textarea_render_rect(textarea_rect);
if render_rect.is_empty() {
return;
}
let prompt_x = if self.prompt_inset_enabled {
textarea_rect
.x
.checked_sub(LIVE_PREFIX_COLS)
.unwrap_or(textarea_rect.x)
} else {
textarea_rect.x
};
let prompt = if self.input_enabled {
"›".bold()
} else {
"›".dim()
};
buf.set_span(prompt_x, textarea_rect.y, &prompt, render_rect.width);
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), render_rect, buf, &mut state);
if self.textarea.text().is_empty() {
let text = if self.input_enabled {
self.placeholder_text.as_str().to_string()
} else {
self.input_disabled_placeholder
.as_deref()
.unwrap_or("Input disabled.")
.to_string()
};
let placeholder = Span::from(text).dim();
Line::from(vec![placeholder]).render_ref(render_rect.inner(Margin::new(0, 0)), buf);
}
}
fn footer_spacing(footer_hint_height: u16) -> u16 {
if footer_hint_height == 0 {
0
@@ -1628,7 +1813,9 @@ impl ChatComposer {
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) {
if self.popups_enabled
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin =
@@ -1714,21 +1901,24 @@ impl ChatComposer {
// the '/name' token and our caret-based heuristic hides the popup,
// but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit
// literal text.
if let Some(result) = self.try_dispatch_bare_slash_command() {
if self.popups_enabled
&& let Some(result) = self.try_dispatch_bare_slash_command()
{
return (result, true);
}
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
let in_slash_context = self.popups_enabled
&& (matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !self.disable_paste_burst
&& self.paste_burst.is_active()
&& !in_slash_context
@@ -1757,7 +1947,9 @@ impl ChatComposer {
.map(|img| img.path.clone())
.collect::<Vec<_>>();
let original_pending_pastes = self.pending_pastes.clone();
if let Some(result) = self.try_dispatch_slash_command_with_args() {
if self.popups_enabled
&& let Some(result) = self.try_dispatch_slash_command_with_args()
{
return (result, true);
}
@@ -2176,6 +2368,10 @@ impl ChatComposer {
}
fn sync_popups(&mut self) {
if !self.popups_enabled {
self.active_popup = ActivePopup::None;
return;
}
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
@@ -2446,108 +2642,66 @@ impl Renderable for ChatComposer {
let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
let render_rect = self.textarea_render_rect(textarea_rect);
if render_rect.is_empty() {
return None;
}
self.textarea.cursor_pos_with_state(render_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;
let footer_total_height = if self.footer_enabled {
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);
footer_hint_height + footer_spacing
} else {
0
};
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ match &self.active_popup {
let popup_height = if self.popups_enabled {
match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Skill(c) => c.calculate_required_height(width),
}
} else {
footer_total_height
};
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ popup_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) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::Skill(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let footer_props = self.footer_props();
let custom_height = self.custom_footer_height();
let footer_hint_height =
custom_height.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(footer_spacing),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
let mut left_content_width = None;
if self.footer_flash_visible() {
if let Some(flash) = self.footer_flash.as_ref() {
flash.line.render(inset_footer_hint_area(hint_rect), buf);
left_content_width = Some(flash.line.width() as u16);
}
} else if let Some(items) = self.footer_hint_override.as_ref() {
render_footer_hint_items(hint_rect, buf, items);
left_content_width = Some(footer_hint_items_width(items));
} else {
render_footer(hint_rect, buf, footer_props);
left_content_width = Some(footer_line_width(footer_props));
if self.popups_enabled {
match &self.active_popup {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::Skill(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
if self.footer_enabled {
self.render_footer(popup_rect, buf);
}
}
render_mode_indicator(
hint_rect,
buf,
self.collaboration_mode_indicator,
!footer_props.is_task_running,
left_content_width,
);
}
} else if self.footer_enabled {
self.render_footer(popup_rect, buf);
}
let style = user_message_style();
Block::default().style(style).render_ref(composer_rect, buf);
if !textarea_rect.is_empty() {
let prompt = if self.input_enabled {
"›".bold()
} else {
"›".dim()
};
buf.set_span(
textarea_rect.x - LIVE_PREFIX_COLS,
textarea_rect.y,
&prompt,
textarea_rect.width,
);
}
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
let text = if self.input_enabled {
self.placeholder_text.as_str().to_string()
} else {
self.input_disabled_placeholder
.as_deref()
.unwrap_or("Input disabled.")
.to_string()
};
let placeholder = Span::from(text).dim();
Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
}
self.render_textarea(textarea_rect, buf);
}
}
+41 -4
View File
@@ -105,6 +105,7 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
pub(crate) use chat_composer::default_chat_composer;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
@@ -130,6 +131,7 @@ pub(crate) struct BottomPane {
frame_requester: FrameRequester,
has_input_focus: bool,
enhanced_keys_supported: bool,
is_task_running: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
@@ -167,7 +169,7 @@ impl BottomPane {
animations_enabled,
skills,
} = params;
let mut composer = ChatComposer::new(
let mut composer = default_chat_composer(
has_input_focus,
app_event_tx.clone(),
enhanced_keys_supported,
@@ -182,6 +184,7 @@ impl BottomPane {
app_event_tx,
frame_requester,
has_input_focus,
enhanced_keys_supported,
is_task_running: false,
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
@@ -246,6 +249,7 @@ impl BottomPane {
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// If a modal/view is active, handle it here; otherwise forward to composer.
if let Some(view) = self.view_stack.last_mut() {
let mut paste_burst_delay = None;
if key_event.code == KeyCode::Esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete()
@@ -257,9 +261,14 @@ impl BottomPane {
if view.is_complete() {
self.view_stack.clear();
self.on_active_view_complete();
} else if view.is_in_paste_burst() {
paste_burst_delay = view.recommended_redraw_delay();
}
}
self.request_redraw();
if let Some(delay) = paste_burst_delay {
self.request_redraw_in(delay);
}
InputResult::None
} else {
// If a task is running and a status line is visible, allow Esc to
@@ -320,6 +329,10 @@ impl BottomPane {
let needs_redraw = view.handle_paste(pasted);
if view.is_complete() {
self.on_active_view_complete();
} else if view.is_in_paste_burst()
&& let Some(delay) = view.recommended_redraw_delay()
{
self.request_redraw_in(delay);
}
if needs_redraw {
self.request_redraw();
@@ -329,6 +342,9 @@ impl BottomPane {
if needs_redraw {
self.request_redraw();
}
if self.composer.is_in_paste_burst() {
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
}
}
}
@@ -623,7 +639,11 @@ impl BottomPane {
request
};
let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone());
let modal = RequestUserInputOverlay::new(
request,
self.app_event_tx.clone(),
self.enhanced_keys_supported,
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
false,
@@ -665,11 +685,28 @@ impl BottomPane {
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()
if let Some(view) = self.view_stack.last_mut() {
view.flush_paste_burst_if_due()
} else {
self.composer.flush_paste_burst_if_due()
}
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
if let Some(view) = self.view_stack.last() {
view.is_in_paste_burst()
} else {
self.composer.is_in_paste_burst()
}
}
pub(crate) fn recommended_paste_burst_delay(&self) -> Duration {
if let Some(view) = self.view_stack.last() {
view.recommended_redraw_delay()
.unwrap_or_else(ChatComposer::recommended_paste_flush_delay)
} else {
ChatComposer::recommended_paste_flush_delay()
}
}
pub(crate) fn on_history_entry_response(
@@ -6,23 +6,24 @@
//! - Typing while focused on options jumps into notes to keep freeform input fast.
//! - Enter advances to the next question; the last question submits all answers.
//! - Freeform-only questions submit an empty answer list when empty.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::time::Duration;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
mod layout;
mod render;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::default_chat_composer;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_core::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
@@ -31,7 +32,6 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
const NOTES_PLACEHOLDER: &str = "Add notes (optional)";
const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";
const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
@@ -40,16 +40,27 @@ enum Focus {
}
struct NotesEntry {
text: TextArea,
state: RefCell<TextAreaState>,
composer: ChatComposer,
}
impl NotesEntry {
fn new() -> Self {
Self {
text: TextArea::new(),
state: RefCell::new(TextAreaState::default()),
}
fn new(
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: &'static str,
) -> Self {
let mut composer = default_chat_composer(
true,
app_event_tx,
enhanced_keys_supported,
placeholder_text.to_string(),
false,
);
composer.set_popups_enabled(false);
composer.set_footer_enabled(false);
composer.set_prompt_inset_enabled(false);
composer.set_task_running(false);
Self { composer }
}
}
@@ -73,10 +84,15 @@ pub(crate) struct RequestUserInputOverlay {
current_idx: usize,
focus: Focus,
done: bool,
enhanced_keys_supported: bool,
}
impl RequestUserInputOverlay {
pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self {
pub(crate) fn new(
request: RequestUserInputEvent,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
) -> Self {
let mut overlay = Self {
app_event_tx,
request,
@@ -85,6 +101,7 @@ impl RequestUserInputOverlay {
current_idx: 0,
focus: Focus::Options,
done: false,
enhanced_keys_supported,
};
overlay.reset_for_request();
overlay.ensure_focus_available();
@@ -168,20 +185,6 @@ impl RequestUserInputOverlay {
answer.option_notes.get_mut(idx)
}
fn notes_placeholder(&self) -> &'static str {
if self.has_options()
&& self
.current_answer()
.is_some_and(|answer| answer.selected.is_none())
{
SELECT_OPTION_PLACEHOLDER
} else if self.has_options() {
NOTES_PLACEHOLDER
} else {
ANSWER_PLACEHOLDER
}
}
/// Ensure the focus mode is valid for the current question.
fn ensure_focus_available(&mut self) {
if self.question_count() == 0 {
@@ -194,23 +197,44 @@ impl RequestUserInputOverlay {
/// Rebuild local answer state from the current request.
fn reset_for_request(&mut self) {
let app_event_tx = self.app_event_tx.clone();
let enhanced_keys_supported = self.enhanced_keys_supported;
self.answers = self
.request
.questions
.iter()
.map(|question| {
let mut option_state = ScrollState::new();
let mut has_options = false;
let mut option_notes = Vec::new();
if let Some(options) = question.options.as_ref()
&& !options.is_empty()
{
has_options = true;
option_state.selected_idx = Some(0);
option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect();
option_notes = (0..options.len())
.map(|_| {
NotesEntry::new(
app_event_tx.clone(),
enhanced_keys_supported,
NOTES_PLACEHOLDER,
)
})
.collect();
}
let placeholder_text = if has_options {
NOTES_PLACEHOLDER
} else {
ANSWER_PLACEHOLDER
};
AnswerState {
selected: option_state.selected_idx,
option_state,
notes: NotesEntry::new(),
notes: NotesEntry::new(
app_event_tx.clone(),
enhanced_keys_supported,
placeholder_text,
),
option_notes,
}
})
@@ -282,10 +306,15 @@ impl RequestUserInputOverlay {
let notes = if options.is_some_and(|opts| !opts.is_empty()) {
selected_idx
.and_then(|selected| answer_state.option_notes.get(selected))
.map(|entry| entry.text.text().trim().to_string())
.map(|entry| entry.composer.current_text().trim().to_string())
.unwrap_or_default()
} else {
answer_state.notes.text.text().trim().to_string()
answer_state
.notes
.composer
.current_text()
.trim()
.to_string()
};
let selected_label = selected_idx.and_then(|selected_idx| {
question
@@ -331,7 +360,7 @@ impl RequestUserInputOverlay {
if options.is_some_and(|opts| !opts.is_empty()) {
false
} else {
answer.notes.text.text().trim().is_empty()
answer.notes.composer.current_text().trim().is_empty()
}
})
.count()
@@ -342,8 +371,8 @@ impl RequestUserInputOverlay {
let Some(entry) = self.current_notes_entry() else {
return 3;
};
let usable_width = width.saturating_sub(2);
let text_height = entry.text.desired_height(usable_width).clamp(1, 6);
let composer_height = entry.composer.desired_textarea_height(width);
let text_height = composer_height.saturating_sub(2).clamp(1, 6);
text_height.saturating_add(2).clamp(3, 8)
}
}
@@ -401,14 +430,16 @@ impl BottomPaneView for RequestUserInputOverlay {
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
entry.composer.handle_key_event(key_event);
}
}
_ => {}
}
}
Focus::Notes => {
if matches!(key_event.code, KeyCode::Enter) {
if matches!(key_event.code, KeyCode::Enter)
&& key_event.modifiers == KeyModifiers::NONE
{
self.go_next_or_submit();
return;
}
@@ -433,12 +464,30 @@ impl BottomPaneView for RequestUserInputOverlay {
// Notes are per option when options exist.
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
entry.composer.handle_key_event(key_event);
}
}
}
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.composer.flush_paste_burst_if_due()
} else {
false
}
}
fn is_in_paste_burst(&self) -> bool {
self.current_notes_entry()
.is_some_and(|entry| entry.composer.is_in_paste_burst())
}
fn recommended_redraw_delay(&self) -> Option<Duration> {
Some(ChatComposer::recommended_paste_flush_delay())
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.done = true;
@@ -453,21 +502,14 @@ impl BottomPaneView for RequestUserInputOverlay {
if pasted.is_empty() {
return false;
}
if matches!(self.focus, Focus::Notes) {
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
if matches!(self.focus, Focus::Options) {
// Treat pastes the same as typing: switch into notes.
self.focus = Focus::Notes;
}
if matches!(self.focus, Focus::Notes) {
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
return entry.composer.handle_paste(pasted);
}
return true;
}
@@ -569,6 +611,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
);
overlay.try_consume_user_input_request(request_event(
"turn-2",
@@ -592,6 +635,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
);
overlay.submit_answers();
@@ -611,6 +655,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
);
overlay.submit_answers();
@@ -629,6 +674,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
);
{
@@ -639,7 +685,7 @@ mod tests {
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.composer
.insert_str("Notes for option 2");
overlay.submit_answers();
@@ -664,6 +710,7 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
);
let area = Rect::new(0, 0, 64, 16);
insta::assert_snapshot!(
@@ -678,6 +725,7 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
);
let area = Rect::new(0, 0, 60, 8);
insta::assert_snapshot!(
@@ -721,6 +769,7 @@ mod tests {
}],
),
tx,
true,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
@@ -740,6 +789,7 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
);
let area = Rect::new(0, 0, 64, 10);
insta::assert_snapshot!(
@@ -754,12 +804,13 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
);
overlay.focus = Focus::Notes;
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.composer
.insert_str("Notes");
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));
@@ -5,7 +5,6 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
@@ -251,18 +250,9 @@ impl RequestUserInputOverlay {
width: input_area.width.saturating_sub(prefix_width),
height: 1,
};
let state = *entry.state.borrow();
return entry.text.cursor_pos_with_state(textarea_rect, state);
return entry.composer.cursor_pos_textarea_only(textarea_rect);
}
let text_area_height = input_area.height.saturating_sub(2);
let textarea_rect = Rect {
x: input_area.x.saturating_add(1),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let state = *entry.state.borrow();
entry.text.cursor_pos_with_state(textarea_rect, state)
entry.composer.cursor_pos_textarea_only(input_area)
}
/// Render the notes input box or inline notes field.
@@ -296,65 +286,12 @@ impl RequestUserInputOverlay {
width: area.width.saturating_sub(prefix_width),
height: 1,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim()))
.render(textarea_rect, buf);
}
entry.composer.render_textarea_only(textarea_rect, buf);
return;
}
// Draw a light ASCII frame around the notes area.
let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize));
let bottom_border = top_border.clone();
Paragraph::new(Line::from(top_border)).render(
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
buf,
);
Paragraph::new(Line::from(bottom_border)).render(
Rect {
x: area.x,
y: area.y.saturating_add(area.height.saturating_sub(1)),
width: area.width,
height: 1,
},
buf,
);
for row in 1..area.height.saturating_sub(1) {
Line::from(vec![
"|".into(),
" ".repeat(area.width.saturating_sub(2) as usize).into(),
"|".into(),
])
.render(
Rect {
x: area.x,
y: area.y.saturating_add(row),
width: area.width,
height: 1,
},
buf,
);
}
let text_area_height = area.height.saturating_sub(2);
let textarea_rect = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf);
}
Clear.render(area, buf);
entry.composer.render_textarea_only(area, buf);
}
fn focus_is_options(&self) -> bool {
@@ -371,5 +308,5 @@ impl RequestUserInputOverlay {
}
fn notes_prefix() -> &'static str {
"Notes: "
"Notes"
}
@@ -5,8 +5,8 @@ expression: "render_snapshot(&overlay, area)"
Question 1/1
Goal
Share details.
+--------------------------------------------------------------+
|Type your answer (optional) |
+--------------------------------------------------------------+
› Type your answer (optional)
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt
@@ -14,7 +14,7 @@ Answer
Notes for Option 1 (optional)
+--------------------------------------------------------------+
|Add notes (optional) |
+--------------------------------------------------------------+
› Add notes (optional)
Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt
@@ -10,5 +10,5 @@ What would you like to do next?
( ) Run tests Pick a crate and run its
tests.
( ) Review a diff Summarize or review current
Notes: Add notes (optional)
Notes› Add notes (optional)
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt
@@ -8,5 +8,5 @@ Choose an option.
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes: Add notes (optional)
Notes› Add notes (optional)
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter
+1 -3
View File
@@ -2713,9 +2713,7 @@ impl ChatWidget {
} else if self.bottom_pane.is_in_paste_burst() {
// While capturing a burst, schedule a follow-up tick and skip this frame
// to avoid redundant renders between ticks.
frame_requester.schedule_frame_in(
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
);
frame_requester.schedule_frame_in(self.bottom_pane.recommended_paste_burst_delay());
true
} else {
false
@@ -13,6 +13,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::default_chat_composer;
use crate::render::renderable::Renderable;
/// Action returned from feeding a key event into the ComposerInput.
@@ -37,7 +38,8 @@ impl ComposerInput {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let sender = AppEventSender::new(tx.clone());
// `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior.
let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false);
let inner =
default_chat_composer(true, sender, true, "Compose new task".to_string(), false);
Self { inner, _tx: tx, rx }
}