Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Ibrahim
a5eaf21a13 unify 2026-01-25 21:43:49 -08:00
Ahmed Ibrahim
1b77070923 unify 2026-01-25 21:25:36 -08:00
Ahmed Ibrahim
868d23f878 unify 2026-01-25 21:13:49 -08:00
11 changed files with 304 additions and 183 deletions

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputEvent;
@@ -27,6 +29,22 @@ pub(crate) trait BottomPaneView: Renderable {
false 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 /// Try to handle approval request; return the original value if not
/// consumed. /// consumed.
fn try_consume_approval_request( fn try_consume_approval_request(

View File

@@ -253,6 +253,22 @@ enum ActivePopup {
const FOOTER_SPACING_HEIGHT: u16 = 0; 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 { impl ChatComposer {
pub fn new( pub fn new(
has_input_focus: bool, has_input_focus: bool,
@@ -320,6 +336,47 @@ impl ChatComposer {
self.collaboration_modes_enabled = enabled; self.collaboration_modes_enabled = enabled;
} }
pub(crate) fn desired_textarea_height(&self, width: u16) -> u16 {
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
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( pub fn set_collaboration_mode_indicator(
&mut self, &mut self,
indicator: Option<CollaborationModeIndicator>, indicator: Option<CollaborationModeIndicator>,
@@ -350,6 +407,50 @@ impl ChatComposer {
[composer_rect, textarea_rect, popup_rect] [composer_rect, textarea_rect, popup_rect]
} }
fn textarea_render_rect(&self, textarea_rect: Rect) -> Rect {
if 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
}
}
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 = textarea_rect
.x
.checked_sub(LIVE_PREFIX_COLS)
.unwrap_or(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 { fn footer_spacing(footer_hint_height: u16) -> u16 {
if footer_hint_height == 0 { if footer_hint_height == 0 {
0 0
@@ -2446,7 +2547,11 @@ impl Renderable for ChatComposer {
let [_, textarea_rect, _] = self.layout_areas(area); let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow(); 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 { fn desired_height(&self, width: u16) -> u16 {
@@ -2457,15 +2562,16 @@ impl Renderable for ChatComposer {
let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing; let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let popup_height = 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),
};
self.textarea self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN)) .desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2 + 2
+ match &self.active_popup { + popup_height
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),
}
} }
fn render(&self, area: Rect, buf: &mut Buffer) { fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -2496,19 +2602,18 @@ impl Renderable for ChatComposer {
} else { } else {
popup_rect popup_rect
}; };
let mut left_content_width = None; let left_content_width = if self.footer_flash_visible()
if self.footer_flash_visible() { && let Some(flash) = self.footer_flash.as_ref()
if let Some(flash) = self.footer_flash.as_ref() { {
flash.line.render(inset_footer_hint_area(hint_rect), buf); flash.line.render(inset_footer_hint_area(hint_rect), buf);
left_content_width = Some(flash.line.width() as u16); Some(flash.line.width() as u16)
}
} else if let Some(items) = self.footer_hint_override.as_ref() { } else if let Some(items) = self.footer_hint_override.as_ref() {
render_footer_hint_items(hint_rect, buf, items); render_footer_hint_items(hint_rect, buf, items);
left_content_width = Some(footer_hint_items_width(items)); Some(footer_hint_items_width(items))
} else { } else {
render_footer(hint_rect, buf, footer_props); render_footer(hint_rect, buf, footer_props);
left_content_width = Some(footer_line_width(footer_props)); Some(footer_line_width(footer_props))
} };
render_mode_indicator( render_mode_indicator(
hint_rect, hint_rect,
buf, buf,
@@ -2520,34 +2625,7 @@ impl Renderable for ChatComposer {
} }
let style = user_message_style(); let style = user_message_style();
Block::default().style(style).render_ref(composer_rect, buf); Block::default().style(style).render_ref(composer_rect, buf);
if !textarea_rect.is_empty() { self.render_textarea(textarea_rect, buf);
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);
}
} }
} }

View File

@@ -105,6 +105,7 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::InputResult;
pub(crate) use chat_composer::default_chat_composer;
use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget; use crate::status_indicator_widget::StatusIndicatorWidget;
@@ -130,6 +131,7 @@ pub(crate) struct BottomPane {
frame_requester: FrameRequester, frame_requester: FrameRequester,
has_input_focus: bool, has_input_focus: bool,
enhanced_keys_supported: bool,
is_task_running: bool, is_task_running: bool,
esc_backtrack_hint: bool, esc_backtrack_hint: bool,
animations_enabled: bool, animations_enabled: bool,
@@ -167,7 +169,7 @@ impl BottomPane {
animations_enabled, animations_enabled,
skills, skills,
} = params; } = params;
let mut composer = ChatComposer::new( let mut composer = default_chat_composer(
has_input_focus, has_input_focus,
app_event_tx.clone(), app_event_tx.clone(),
enhanced_keys_supported, enhanced_keys_supported,
@@ -182,6 +184,7 @@ impl BottomPane {
app_event_tx, app_event_tx,
frame_requester, frame_requester,
has_input_focus, has_input_focus,
enhanced_keys_supported,
is_task_running: false, is_task_running: false,
status: None, status: None,
unified_exec_footer: UnifiedExecFooter::new(), unified_exec_footer: UnifiedExecFooter::new(),
@@ -246,6 +249,7 @@ impl BottomPane {
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { 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 a modal/view is active, handle it here; otherwise forward to composer.
if let Some(view) = self.view_stack.last_mut() { if let Some(view) = self.view_stack.last_mut() {
let mut paste_burst_delay = None;
if key_event.code == KeyCode::Esc if key_event.code == KeyCode::Esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled) && matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete() && view.is_complete()
@@ -257,9 +261,14 @@ impl BottomPane {
if view.is_complete() { if view.is_complete() {
self.view_stack.clear(); self.view_stack.clear();
self.on_active_view_complete(); self.on_active_view_complete();
} else if view.is_in_paste_burst() {
paste_burst_delay = view.recommended_redraw_delay();
} }
} }
self.request_redraw(); self.request_redraw();
if let Some(delay) = paste_burst_delay {
self.request_redraw_in(delay);
}
InputResult::None InputResult::None
} else { } else {
// If a task is running and a status line is visible, allow Esc to // 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); let needs_redraw = view.handle_paste(pasted);
if view.is_complete() { if view.is_complete() {
self.on_active_view_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 { if needs_redraw {
self.request_redraw(); self.request_redraw();
@@ -329,6 +342,9 @@ impl BottomPane {
if needs_redraw { if needs_redraw {
self.request_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 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.pause_status_timer_for_modal();
self.set_composer_input_enabled( self.set_composer_input_enabled(
false, false,
@@ -665,11 +685,28 @@ impl BottomPane {
} }
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { 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 { 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( pub(crate) fn on_history_entry_response(

View File

@@ -6,23 +6,24 @@
//! - Typing while focused on options jumps into notes to keep freeform input fast. //! - 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. //! - Enter advances to the next question; the last question submits all answers.
//! - Freeform-only questions submit an empty answer list when empty. //! - Freeform-only questions submit an empty answer list when empty.
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::Duration;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
mod layout; mod layout;
mod render; mod render;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::bottom_pane_view::BottomPaneView; 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::scroll_state::ScrollState;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_core::protocol::Op; use codex_core::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer; 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 NOTES_PLACEHOLDER: &str = "Add notes (optional)";
const ANSWER_PLACEHOLDER: &str = "Type your answer (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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus { enum Focus {
@@ -40,16 +40,24 @@ enum Focus {
} }
struct NotesEntry { struct NotesEntry {
text: TextArea, composer: ChatComposer,
state: RefCell<TextAreaState>,
} }
impl NotesEntry { impl NotesEntry {
fn new() -> Self { fn new(
Self { app_event_tx: AppEventSender,
text: TextArea::new(), enhanced_keys_supported: bool,
state: RefCell::new(TextAreaState::default()), 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_task_running(false);
Self { composer }
} }
} }
@@ -73,10 +81,15 @@ pub(crate) struct RequestUserInputOverlay {
current_idx: usize, current_idx: usize,
focus: Focus, focus: Focus,
done: bool, done: bool,
enhanced_keys_supported: bool,
} }
impl RequestUserInputOverlay { 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 { let mut overlay = Self {
app_event_tx, app_event_tx,
request, request,
@@ -85,6 +98,7 @@ impl RequestUserInputOverlay {
current_idx: 0, current_idx: 0,
focus: Focus::Options, focus: Focus::Options,
done: false, done: false,
enhanced_keys_supported,
}; };
overlay.reset_for_request(); overlay.reset_for_request();
overlay.ensure_focus_available(); overlay.ensure_focus_available();
@@ -168,20 +182,6 @@ impl RequestUserInputOverlay {
answer.option_notes.get_mut(idx) 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. /// Ensure the focus mode is valid for the current question.
fn ensure_focus_available(&mut self) { fn ensure_focus_available(&mut self) {
if self.question_count() == 0 { if self.question_count() == 0 {
@@ -194,23 +194,44 @@ impl RequestUserInputOverlay {
/// Rebuild local answer state from the current request. /// Rebuild local answer state from the current request.
fn reset_for_request(&mut self) { 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 self.answers = self
.request .request
.questions .questions
.iter() .iter()
.map(|question| { .map(|question| {
let mut option_state = ScrollState::new(); let mut option_state = ScrollState::new();
let mut has_options = false;
let mut option_notes = Vec::new(); let mut option_notes = Vec::new();
if let Some(options) = question.options.as_ref() if let Some(options) = question.options.as_ref()
&& !options.is_empty() && !options.is_empty()
{ {
has_options = true;
option_state.selected_idx = Some(0); 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 { AnswerState {
selected: option_state.selected_idx, selected: option_state.selected_idx,
option_state, option_state,
notes: NotesEntry::new(), notes: NotesEntry::new(
app_event_tx.clone(),
enhanced_keys_supported,
placeholder_text,
),
option_notes, option_notes,
} }
}) })
@@ -282,10 +303,15 @@ impl RequestUserInputOverlay {
let notes = if options.is_some_and(|opts| !opts.is_empty()) { let notes = if options.is_some_and(|opts| !opts.is_empty()) {
selected_idx selected_idx
.and_then(|selected| answer_state.option_notes.get(selected)) .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() .unwrap_or_default()
} else { } 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| { let selected_label = selected_idx.and_then(|selected_idx| {
question question
@@ -331,7 +357,7 @@ impl RequestUserInputOverlay {
if options.is_some_and(|opts| !opts.is_empty()) { if options.is_some_and(|opts| !opts.is_empty()) {
false false
} else { } else {
answer.notes.text.text().trim().is_empty() answer.notes.composer.current_text().trim().is_empty()
} }
}) })
.count() .count()
@@ -342,8 +368,8 @@ impl RequestUserInputOverlay {
let Some(entry) = self.current_notes_entry() else { let Some(entry) = self.current_notes_entry() else {
return 3; return 3;
}; };
let usable_width = width.saturating_sub(2); let composer_height = entry.composer.desired_textarea_height(width);
let text_height = entry.text.desired_height(usable_width).clamp(1, 6); let text_height = composer_height.saturating_sub(2).clamp(1, 6);
text_height.saturating_add(2).clamp(3, 8) text_height.saturating_add(2).clamp(3, 8)
} }
} }
@@ -401,14 +427,16 @@ impl BottomPaneView for RequestUserInputOverlay {
self.focus = Focus::Notes; self.focus = Focus::Notes;
self.ensure_selected_for_notes(); self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() { if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event); entry.composer.handle_key_event(key_event);
} }
} }
_ => {} _ => {}
} }
} }
Focus::Notes => { 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(); self.go_next_or_submit();
return; return;
} }
@@ -433,12 +461,30 @@ impl BottomPaneView for RequestUserInputOverlay {
// Notes are per option when options exist. // Notes are per option when options exist.
self.ensure_selected_for_notes(); self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() { 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 { fn on_ctrl_c(&mut self) -> CancellationEvent {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.done = true; self.done = true;
@@ -453,21 +499,14 @@ impl BottomPaneView for RequestUserInputOverlay {
if pasted.is_empty() { if pasted.is_empty() {
return false; 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) { if matches!(self.focus, Focus::Options) {
// Treat pastes the same as typing: switch into notes. // Treat pastes the same as typing: switch into notes.
self.focus = Focus::Notes; self.focus = Focus::Notes;
}
if matches!(self.focus, Focus::Notes) {
self.ensure_selected_for_notes(); self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() { if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted); return entry.composer.handle_paste(pasted);
return true;
} }
return true; return true;
} }
@@ -569,6 +608,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new( let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]), request_event("turn-1", vec![question_with_options("q1", "First")]),
tx, tx,
true,
); );
overlay.try_consume_user_input_request(request_event( overlay.try_consume_user_input_request(request_event(
"turn-2", "turn-2",
@@ -592,6 +632,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new( let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]), request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx, tx,
true,
); );
overlay.submit_answers(); overlay.submit_answers();
@@ -611,6 +652,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new( let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]), request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx, tx,
true,
); );
overlay.submit_answers(); overlay.submit_answers();
@@ -629,6 +671,7 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new( let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]), request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx, tx,
true,
); );
{ {
@@ -639,7 +682,7 @@ mod tests {
overlay overlay
.current_notes_entry_mut() .current_notes_entry_mut()
.expect("notes entry missing") .expect("notes entry missing")
.text .composer
.insert_str("Notes for option 2"); .insert_str("Notes for option 2");
overlay.submit_answers(); overlay.submit_answers();
@@ -664,6 +707,7 @@ mod tests {
let overlay = RequestUserInputOverlay::new( let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]), request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx, tx,
true,
); );
let area = Rect::new(0, 0, 64, 16); let area = Rect::new(0, 0, 64, 16);
insta::assert_snapshot!( insta::assert_snapshot!(
@@ -678,6 +722,7 @@ mod tests {
let overlay = RequestUserInputOverlay::new( let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]), request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx, tx,
true,
); );
let area = Rect::new(0, 0, 60, 8); let area = Rect::new(0, 0, 60, 8);
insta::assert_snapshot!( insta::assert_snapshot!(
@@ -721,6 +766,7 @@ mod tests {
}], }],
), ),
tx, tx,
true,
); );
{ {
let answer = overlay.current_answer_mut().expect("answer missing"); let answer = overlay.current_answer_mut().expect("answer missing");
@@ -740,6 +786,7 @@ mod tests {
let overlay = RequestUserInputOverlay::new( let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]), request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx, tx,
true,
); );
let area = Rect::new(0, 0, 64, 10); let area = Rect::new(0, 0, 64, 10);
insta::assert_snapshot!( insta::assert_snapshot!(
@@ -754,12 +801,13 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new( let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]), request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx, tx,
true,
); );
overlay.focus = Focus::Notes; overlay.focus = Focus::Notes;
overlay overlay
.current_notes_entry_mut() .current_notes_entry_mut()
.expect("notes entry missing") .expect("notes entry missing")
.text .composer
.insert_str("Notes"); .insert_str("Notes");
overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); overlay.handle_key_event(KeyEvent::from(KeyCode::Down));

View File

@@ -5,13 +5,13 @@ use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::widgets::Clear; use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::render_rows; use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint; use crate::key_hint;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::ui_consts::LIVE_PREFIX_COLS;
use super::RequestUserInputOverlay; use super::RequestUserInputOverlay;
@@ -242,27 +242,19 @@ impl RequestUserInputOverlay {
// Inline notes layout uses a prefix and a single-line text area. // Inline notes layout uses a prefix and a single-line text area.
let prefix = notes_prefix(); let prefix = notes_prefix();
let prefix_width = prefix.len() as u16; let prefix_width = prefix.len() as u16;
if input_area.width <= prefix_width { let min_width = prefix_width.saturating_add(LIVE_PREFIX_COLS);
if input_area.width <= min_width {
return None; return None;
} }
let textarea_rect = Rect { let textarea_rect = Rect {
x: input_area.x.saturating_add(prefix_width), x: input_area.x.saturating_add(min_width),
y: input_area.y, y: input_area.y,
width: input_area.width.saturating_sub(prefix_width), width: input_area.width.saturating_sub(min_width),
height: 1, height: 1,
}; };
let state = *entry.state.borrow(); return entry.composer.cursor_pos_textarea_only(textarea_rect);
return entry.text.cursor_pos_with_state(textarea_rect, state);
} }
let text_area_height = input_area.height.saturating_sub(2); entry.composer.cursor_pos_textarea_only(input_area)
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)
} }
/// Render the notes input box or inline notes field. /// Render the notes input box or inline notes field.
@@ -277,7 +269,8 @@ impl RequestUserInputOverlay {
// Inline notes field for tight layouts. // Inline notes field for tight layouts.
let prefix = notes_prefix(); let prefix = notes_prefix();
let prefix_width = prefix.len() as u16; let prefix_width = prefix.len() as u16;
if area.width <= prefix_width { let min_width = prefix_width.saturating_add(LIVE_PREFIX_COLS);
if area.width <= min_width {
Paragraph::new(Line::from(prefix.dim())).render(area, buf); Paragraph::new(Line::from(prefix.dim())).render(area, buf);
return; return;
} }
@@ -291,70 +284,17 @@ impl RequestUserInputOverlay {
buf, buf,
); );
let textarea_rect = Rect { let textarea_rect = Rect {
x: area.x.saturating_add(prefix_width), x: area.x.saturating_add(min_width),
y: area.y, y: area.y,
width: area.width.saturating_sub(prefix_width), width: area.width.saturating_sub(min_width),
height: 1, height: 1,
}; };
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf); Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); entry.composer.render_textarea_only(textarea_rect, buf);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim()))
.render(textarea_rect, buf);
}
return; return;
} }
// Draw a light ASCII frame around the notes area. Clear.render(area, buf);
let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize)); entry.composer.render_textarea_only(area, buf);
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);
}
} }
fn focus_is_options(&self) -> bool { fn focus_is_options(&self) -> bool {
@@ -371,5 +311,5 @@ impl RequestUserInputOverlay {
} }
fn notes_prefix() -> &'static str { fn notes_prefix() -> &'static str {
"Notes: " "Notes"
} }

View File

@@ -5,8 +5,8 @@ expression: "render_snapshot(&overlay, area)"
Question 1/1 Question 1/1
Goal Goal
Share details. Share details.
+--------------------------------------------------------------+
|Type your answer (optional) | Type your answer (optional)
+--------------------------------------------------------------+
Unanswered: 1 | Will submit as skipped Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt ↑/↓ scroll | enter next question | esc interrupt

View File

@@ -14,7 +14,7 @@ Answer
Notes for Option 1 (optional) Notes for Option 1 (optional)
+--------------------------------------------------------------+
|Add notes (optional) | Add notes (optional)
+--------------------------------------------------------------+
Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt

View File

@@ -10,5 +10,5 @@ What would you like to do next?
( ) Run tests Pick a crate and run its ( ) Run tests Pick a crate and run its
tests. tests.
( ) Review a diff Summarize or review current ( ) 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 Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt

View File

@@ -8,5 +8,5 @@ Choose an option.
(x) Option 1 First choice. (x) Option 1 First choice.
( ) Option 2 Second choice. ( ) Option 2 Second choice.
( ) Option 3 Third choice. ( ) Option 3 Third choice.
Notes: Add notes (optional) Notes Add notes (optional)
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter

View File

@@ -2713,9 +2713,7 @@ impl ChatWidget {
} else if self.bottom_pane.is_in_paste_burst() { } else if self.bottom_pane.is_in_paste_burst() {
// While capturing a burst, schedule a follow-up tick and skip this frame // While capturing a burst, schedule a follow-up tick and skip this frame
// to avoid redundant renders between ticks. // to avoid redundant renders between ticks.
frame_requester.schedule_frame_in( frame_requester.schedule_frame_in(self.bottom_pane.recommended_paste_burst_delay());
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
);
true true
} else { } else {
false false

View File

@@ -13,6 +13,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ChatComposer; use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult; use crate::bottom_pane::InputResult;
use crate::bottom_pane::default_chat_composer;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
/// Action returned from feeding a key event into the ComposerInput. /// 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 (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let sender = AppEventSender::new(tx.clone()); let sender = AppEventSender::new(tx.clone());
// `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. // `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 } Self { inner, _tx: tx, rx }
} }