mirror of
https://github.com/openai/codex.git
synced 2026-02-03 07:23:39 +00:00
Compare commits
3 Commits
dev/cc/new
...
composer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5eaf21a13 | ||
|
|
1b77070923 | ||
|
|
868d23f878 |
@@ -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(
|
||||
|
||||
@@ -253,6 +253,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,
|
||||
@@ -320,6 +336,47 @@ impl ChatComposer {
|
||||
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(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
@@ -350,6 +407,50 @@ impl ChatComposer {
|
||||
[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 {
|
||||
if footer_hint_height == 0 {
|
||||
0
|
||||
@@ -2446,7 +2547,11 @@ 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 {
|
||||
@@ -2457,15 +2562,16 @@ impl Renderable for ChatComposer {
|
||||
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;
|
||||
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
|
||||
.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(),
|
||||
ActivePopup::Skill(c) => c.calculate_required_height(width),
|
||||
}
|
||||
+ popup_height
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -2496,19 +2602,18 @@ impl Renderable for ChatComposer {
|
||||
} 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);
|
||||
}
|
||||
let left_content_width = if self.footer_flash_visible()
|
||||
&& let Some(flash) = self.footer_flash.as_ref()
|
||||
{
|
||||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||||
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));
|
||||
Some(footer_hint_items_width(items))
|
||||
} else {
|
||||
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(
|
||||
hint_rect,
|
||||
buf,
|
||||
@@ -2520,34 +2625,7 @@ impl Renderable for ChatComposer {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,24 @@ 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_task_running(false);
|
||||
Self { composer }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +81,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 +98,7 @@ impl RequestUserInputOverlay {
|
||||
current_idx: 0,
|
||||
focus: Focus::Options,
|
||||
done: false,
|
||||
enhanced_keys_supported,
|
||||
};
|
||||
overlay.reset_for_request();
|
||||
overlay.ensure_focus_available();
|
||||
@@ -168,20 +182,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 +194,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 +303,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 +357,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 +368,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 +427,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 +461,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 +499,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 +608,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 +632,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 +652,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 +671,7 @@ mod tests {
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
|
||||
tx,
|
||||
true,
|
||||
);
|
||||
|
||||
{
|
||||
@@ -639,7 +682,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 +707,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 +722,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 +766,7 @@ mod tests {
|
||||
}],
|
||||
),
|
||||
tx,
|
||||
true,
|
||||
);
|
||||
{
|
||||
let answer = overlay.current_answer_mut().expect("answer missing");
|
||||
@@ -740,6 +786,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 +801,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,13 +5,13 @@ 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;
|
||||
use crate::bottom_pane::selection_popup_common::render_rows;
|
||||
use crate::key_hint;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
|
||||
use super::RequestUserInputOverlay;
|
||||
|
||||
@@ -242,27 +242,19 @@ impl RequestUserInputOverlay {
|
||||
// Inline notes layout uses a prefix and a single-line text area.
|
||||
let prefix = notes_prefix();
|
||||
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;
|
||||
}
|
||||
let textarea_rect = Rect {
|
||||
x: input_area.x.saturating_add(prefix_width),
|
||||
x: input_area.x.saturating_add(min_width),
|
||||
y: input_area.y,
|
||||
width: input_area.width.saturating_sub(prefix_width),
|
||||
width: input_area.width.saturating_sub(min_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.
|
||||
@@ -277,7 +269,8 @@ impl RequestUserInputOverlay {
|
||||
// Inline notes field for tight layouts.
|
||||
let prefix = notes_prefix();
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -291,70 +284,17 @@ impl RequestUserInputOverlay {
|
||||
buf,
|
||||
);
|
||||
let textarea_rect = Rect {
|
||||
x: area.x.saturating_add(prefix_width),
|
||||
x: area.x.saturating_add(min_width),
|
||||
y: area.y,
|
||||
width: area.width.saturating_sub(prefix_width),
|
||||
width: area.width.saturating_sub(min_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 +311,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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user