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::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(

View File

@@ -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);
}
}

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(

View File

@@ -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));

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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 }
}