custom textarea (#1794)

This replaces tui-textarea with a custom textarea component.

Key differences:
1. wrapped lines
2. better unicode handling
3. uses the native terminal cursor

This should perhaps be spun out into its own separate crate at some
point, but for now it's convenient to have it in-tree.
This commit is contained in:
Jeremy Rose
2025-08-03 11:31:35 -07:00
committed by GitHub
parent 4c9f7b6bcc
commit d62b703a21
8 changed files with 1690 additions and 409 deletions

39
codex-rs/Cargo.lock generated
View File

@@ -859,6 +859,7 @@ dependencies = [
"mcp-types", "mcp-types",
"path-clean", "path-clean",
"pretty_assertions", "pretty_assertions",
"rand 0.8.5",
"ratatui", "ratatui",
"ratatui-image", "ratatui-image",
"regex-lite", "regex-lite",
@@ -868,13 +869,13 @@ dependencies = [
"shlex", "shlex",
"strum 0.27.2", "strum 0.27.2",
"strum_macros 0.27.2", "strum_macros 0.27.2",
"textwrap 0.16.2",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"tui-input", "tui-input",
"tui-markdown", "tui-markdown",
"tui-textarea",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.1.14", "unicode-width 0.1.14",
"uuid", "uuid",
@@ -4173,6 +4174,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.10" version = "0.5.10"
@@ -4235,7 +4242,7 @@ dependencies = [
"starlark_syntax", "starlark_syntax",
"static_assertions", "static_assertions",
"strsim 0.10.0", "strsim 0.10.0",
"textwrap", "textwrap 0.11.0",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@@ -4524,6 +4531,17 @@ dependencies = [
"unicode-width 0.1.14", "unicode-width 0.1.14",
] ]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -4988,17 +5006,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tui-textarea"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
dependencies = [
"crossterm",
"ratatui",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.18.0" version = "1.18.0"
@@ -5017,6 +5024,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.12.0" version = "1.12.0"

View File

@@ -48,6 +48,7 @@ serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0" shlex = "1.3.0"
strum = "0.27.2" strum = "0.27.2"
strum_macros = "0.27.2" strum_macros = "0.27.2"
textwrap = "0.16.2"
tokio = { version = "1", features = [ tokio = { version = "1", features = [
"io-std", "io-std",
"macros", "macros",
@@ -60,7 +61,6 @@ tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tui-input = "0.14.0" tui-input = "0.14.0"
tui-markdown = "0.3.3" tui-markdown = "0.3.3"
tui-textarea = "0.7.0"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
unicode-width = "0.1" unicode-width = "0.1"
uuid = "1" uuid = "1"
@@ -70,3 +70,5 @@ uuid = "1"
[dev-dependencies] [dev-dependencies]
insta = "1.43.1" insta = "1.43.1"
pretty_assertions = "1" pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }

View File

@@ -438,14 +438,15 @@ impl App<'_> {
); );
self.pending_history_lines.clear(); self.pending_history_lines.clear();
} }
match &mut self.app_state { terminal.draw(|frame| match &mut self.app_state {
AppState::Chat { widget } => { AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?; if let Some((x, y)) = widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
frame.render_widget_ref(&**widget, frame.area())
} }
AppState::GitWarning { screen } => { AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?; })?;
}
}
Ok(()) Ok(())
} }

View File

@@ -1,6 +1,11 @@
use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsage;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Style; use ratatui::style::Style;
@@ -8,13 +13,11 @@ use ratatui::style::Styled;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType; use ratatui::widgets::BorderType;
use ratatui::widgets::Borders; use ratatui::widgets::Borders;
use ratatui::widgets::Widget; use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use tui_textarea::Input;
use tui_textarea::Key;
use tui_textarea::TextArea;
use super::chat_composer_history::ChatComposerHistory; use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup; use super::command_popup::CommandPopup;
@@ -22,7 +25,10 @@ use super::file_search_popup::FileSearchPopup;
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::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch; use codex_file_search::FileMatch;
use std::cell::RefCell;
const BASE_PLACEHOLDER_TEXT: &str = "..."; const BASE_PLACEHOLDER_TEXT: &str = "...";
/// If the pasted content exceeds this number of characters, replace it with a /// If the pasted content exceeds this number of characters, replace it with a
@@ -35,8 +41,14 @@ pub enum InputResult {
None, None,
} }
pub(crate) struct ChatComposer<'a> { struct TokenUsageInfo {
textarea: TextArea<'a>, token_usage: TokenUsage,
model_context_window: Option<u64>,
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
active_popup: ActivePopup, active_popup: ActivePopup,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
history: ChatComposerHistory, history: ChatComposerHistory,
@@ -45,6 +57,8 @@ pub(crate) struct ChatComposer<'a> {
dismissed_file_popup_token: Option<String>, dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>, current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>, pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
} }
/// Popup state at most one can be visible at any time. /// Popup state at most one can be visible at any time.
@@ -54,20 +68,17 @@ enum ActivePopup {
File(FileSearchPopup), File(FileSearchPopup),
} }
impl ChatComposer<'_> { impl ChatComposer {
pub fn new( pub fn new(
has_input_focus: bool, has_input_focus: bool,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
enhanced_keys_supported: bool, enhanced_keys_supported: bool,
) -> Self { ) -> Self {
let mut textarea = TextArea::default();
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
textarea.set_cursor_line_style(ratatui::style::Style::default());
let use_shift_enter_hint = enhanced_keys_supported; let use_shift_enter_hint = enhanced_keys_supported;
let mut this = Self { Self {
textarea, textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
active_popup: ActivePopup::None, active_popup: ActivePopup::None,
app_event_tx, app_event_tx,
history: ChatComposerHistory::new(), history: ChatComposerHistory::new(),
@@ -76,13 +87,13 @@ impl ChatComposer<'_> {
dismissed_file_popup_token: None, dismissed_file_popup_token: None,
current_file_query: None, current_file_query: None,
pending_pastes: Vec::new(), pending_pastes: Vec::new(),
}; token_usage_info: None,
this.update_border(has_input_focus); has_focus: has_input_focus,
this }
} }
pub fn desired_height(&self) -> u16 { pub fn desired_height(&self, width: u16) -> u16 {
self.textarea.lines().len().max(1) as u16 self.textarea.desired_height(width - 1)
+ match &self.active_popup { + match &self.active_popup {
ActivePopup::None => 1u16, ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(), ActivePopup::Command(c) => c.calculate_required_height(),
@@ -90,6 +101,21 @@ impl ChatComposer<'_> {
} }
} }
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
ActivePopup::File(popup) => popup.calculate_required_height(),
ActivePopup::None => 1,
};
let [textarea_rect, _] =
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
let mut textarea_rect = textarea_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(1);
textarea_rect.x += 1;
let state = self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, &state)
}
/// Returns true if the composer currently contains no user input. /// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool { pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty() self.textarea.is_empty()
@@ -103,28 +129,10 @@ impl ChatComposer<'_> {
token_usage: TokenUsage, token_usage: TokenUsage,
model_context_window: Option<u64>, model_context_window: Option<u64>,
) { ) {
let placeholder = match (token_usage.total_tokens, model_context_window) { self.token_usage_info = Some(TokenUsageInfo {
(total_tokens, Some(context_window)) => { token_usage,
let percent_remaining: u8 = if context_window > 0 { model_context_window,
// Calculate the percentage of context left. });
let percent = 100.0 - (total_tokens as f32 / context_window as f32 * 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
// If we don't have a context window, we cannot compute the
// percentage.
100
};
// When https://github.com/openai/codex/issues/1257 is resolved,
// check if `percent_remaining < 25`, and if so, recommend
// /compact.
format!("{BASE_PLACEHOLDER_TEXT}{percent_remaining}% context left")
}
(total_tokens, None) => {
format!("{BASE_PLACEHOLDER_TEXT}{total_tokens} tokens used")
}
};
self.textarea.set_placeholder_text(placeholder);
} }
/// Record the history metadata advertised by `SessionConfiguredEvent` so /// Record the history metadata advertised by `SessionConfiguredEvent` so
@@ -142,8 +150,12 @@ impl ChatComposer<'_> {
offset: usize, offset: usize,
entry: Option<String>, entry: Option<String>,
) -> bool { ) -> bool {
self.history let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
.on_entry_response(log_id, offset, entry, &mut self.textarea) return false;
};
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
true
} }
pub fn handle_paste(&mut self, pasted: String) -> bool { pub fn handle_paste(&mut self, pasted: String) -> bool {
@@ -179,7 +191,7 @@ impl ChatComposer<'_> {
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show; self.ctrl_c_quit_hint = show;
self.update_border(has_focus); self.set_has_focus(has_focus);
} }
/// Handle a key event coming from the main UI. /// Handle a key event coming from the main UI.
@@ -207,49 +219,47 @@ impl ChatComposer<'_> {
unreachable!(); unreachable!();
}; };
match key_event.into() { match key_event {
Input { key: Key::Up, .. } => { KeyEvent {
code: KeyCode::Up, ..
} => {
popup.move_up(); popup.move_up();
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Down, .. } => { KeyEvent {
code: KeyCode::Down,
..
} => {
popup.move_down(); popup.move_down();
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Tab, .. } => { KeyEvent {
code: KeyCode::Tab, ..
} => {
if let Some(cmd) = popup.selected_command() { if let Some(cmd) = popup.selected_command() {
let first_line = self let first_line = self.textarea.text().lines().next().unwrap_or("");
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let starts_with_cmd = first_line let starts_with_cmd = first_line
.trim_start() .trim_start()
.starts_with(&format!("/{}", cmd.command())); .starts_with(&format!("/{}", cmd.command()));
if !starts_with_cmd { if !starts_with_cmd {
self.textarea.select_all(); self.textarea.set_text(&format!("/{} ", cmd.command()));
self.textarea.cut();
let _ = self.textarea.insert_str(format!("/{} ", cmd.command()));
} }
} }
(InputResult::None, true) (InputResult::None, true)
} }
Input { KeyEvent {
key: Key::Enter, code: KeyCode::Enter,
shift: false, modifiers: KeyModifiers::NONE,
alt: false, ..
ctrl: false,
} => { } => {
if let Some(cmd) = popup.selected_command() { if let Some(cmd) = popup.selected_command() {
// Send command to the app layer. // Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)); self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
// Clear textarea so no residual text remains. // Clear textarea so no residual text remains.
self.textarea.select_all(); self.textarea.set_text("");
self.textarea.cut();
// Hide popup since the command has been dispatched. // Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None; self.active_popup = ActivePopup::None;
@@ -268,16 +278,23 @@ impl ChatComposer<'_> {
unreachable!(); unreachable!();
}; };
match key_event.into() { match key_event {
Input { key: Key::Up, .. } => { KeyEvent {
code: KeyCode::Up, ..
} => {
popup.move_up(); popup.move_up();
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Down, .. } => { KeyEvent {
code: KeyCode::Down,
..
} => {
popup.move_down(); popup.move_down();
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Esc, .. } => { KeyEvent {
code: KeyCode::Esc, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen. // Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.textarea) { if let Some(tok) = Self::current_at_token(&self.textarea) {
self.dismissed_file_popup_token = Some(tok.to_string()); self.dismissed_file_popup_token = Some(tok.to_string());
@@ -285,12 +302,13 @@ impl ChatComposer<'_> {
self.active_popup = ActivePopup::None; self.active_popup = ActivePopup::None;
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Tab, .. } KeyEvent {
| Input { code: KeyCode::Tab, ..
key: Key::Enter, }
ctrl: false, | KeyEvent {
alt: false, code: KeyCode::Enter,
shift: false, modifiers: KeyModifiers::NONE,
..
} => { } => {
if let Some(sel) = popup.selected_match() { if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string(); let sel_path = sel.to_string();
@@ -315,46 +333,89 @@ impl ChatComposer<'_> {
/// - A token is delimited by ASCII whitespace (space, tab, newline). /// - A token is delimited by ASCII whitespace (space, tab, newline).
/// - If the token under the cursor starts with `@` and contains at least /// - If the token under the cursor starts with `@` and contains at least
/// one additional character, that token (without `@`) is returned. /// one additional character, that token (without `@`) is returned.
fn current_at_token(textarea: &tui_textarea::TextArea) -> Option<String> { fn current_at_token(textarea: &TextArea) -> Option<String> {
let (row, col) = textarea.cursor(); let cursor_offset = textarea.cursor();
let text = textarea.text();
// Guard against out-of-bounds rows. // Adjust the provided byte offset to the nearest valid char boundary at or before it.
let line = textarea.lines().get(row)?.as_str(); let mut safe_cursor = cursor_offset.min(text.len());
// If we're not on a char boundary, move back to the start of the current char.
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
// Find the last valid boundary <= cursor_offset.
safe_cursor = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= cursor_offset)
.last()
.unwrap_or(0);
}
// Calculate byte offset for cursor position // Split the line around the (now safe) cursor position.
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>(); let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Split the line at the cursor position so we can search for word // Detect whether we're on whitespace at the cursor boundary.
// boundaries on both sides. let at_whitespace = if safe_cursor < text.len() {
let before_cursor = &line[..cursor_byte_offset]; text[safe_cursor..]
let after_cursor = &line[cursor_byte_offset..]; .chars()
.next()
.map(|c| c.is_whitespace())
.unwrap_or(false)
} else {
false
};
// Find start index (first character **after** the previous multi-byte whitespace). // Left candidate: token containing the cursor position.
let start_idx = before_cursor let start_left = before_cursor
.char_indices() .char_indices()
.rfind(|(_, c)| c.is_whitespace()) .rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8()) .map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0); .unwrap_or(0);
let end_left_rel = after_cursor
// Find end index (first multi-byte whitespace **after** the cursor position).
let end_rel_idx = after_cursor
.char_indices() .char_indices()
.find(|(_, c)| c.is_whitespace()) .find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx) .map(|(idx, _)| idx)
.unwrap_or(after_cursor.len()); .unwrap_or(after_cursor.len());
let end_idx = cursor_byte_offset + end_rel_idx; let end_left = safe_cursor + end_left_rel;
let token_left = if start_left < end_left {
if start_idx >= end_idx { Some(&text[start_left..end_left])
return None;
}
let token = &line[start_idx..end_idx];
if token.starts_with('@') && token.len() > 1 {
Some(token[1..].to_string())
} else { } else {
None None
};
// Right candidate: token immediately after any whitespace from the cursor.
let ws_len_right: usize = after_cursor
.chars()
.take_while(|c| c.is_whitespace())
.map(|c| c.len_utf8())
.sum();
let start_right = safe_cursor + ws_len_right;
let end_right_rel = text[start_right..]
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(text.len() - start_right);
let end_right = start_right + end_right_rel;
let token_right = if start_right < end_right {
Some(&text[start_right..end_right])
} else {
None
};
let left_at = token_left
.filter(|t| t.starts_with('@') && t.len() > 1)
.map(|t| t[1..].to_string());
let right_at = token_right
.filter(|t| t.starts_with('@') && t.len() > 1)
.map(|t| t[1..].to_string());
if at_whitespace {
return right_at.or(left_at);
} }
if after_cursor.starts_with('@') {
return right_at.or(left_at);
}
left_at.or(right_at)
} }
/// Replace the active `@token` (the one under the cursor) with `path`. /// Replace the active `@token` (the one under the cursor) with `path`.
@@ -363,94 +424,73 @@ impl ChatComposer<'_> {
/// where the cursor is within the token and regardless of how many /// where the cursor is within the token and regardless of how many
/// `@tokens` exist in the line. /// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) { fn insert_selected_path(&mut self, path: &str) {
let (row, col) = self.textarea.cursor(); let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Materialize the textarea lines so we can mutate them easily. let before_cursor = &text[..cursor_offset];
let mut lines: Vec<String> = self.textarea.lines().to_vec(); let after_cursor = &text[cursor_offset..];
if let Some(line) = lines.get_mut(row) { // Determine token boundaries.
// Calculate byte offset for cursor position let start_idx = before_cursor
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>(); .char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let before_cursor = &line[..cursor_byte_offset]; let end_rel_idx = after_cursor
let after_cursor = &line[cursor_byte_offset..]; .char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_offset + end_rel_idx;
// Determine token boundaries. // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let start_idx = before_cursor let mut new_text =
.char_indices() String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
.rfind(|(_, c)| c.is_whitespace()) new_text.push_str(&text[..start_idx]);
.map(|(idx, c)| idx + c.len_utf8()) new_text.push_str(path);
.unwrap_or(0); new_text.push(' ');
new_text.push_str(&text[end_idx..]);
let end_rel_idx = after_cursor self.textarea.set_text(&new_text);
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_byte_offset + end_rel_idx;
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_line =
String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1);
new_line.push_str(&line[..start_idx]);
new_line.push_str(path);
new_line.push(' ');
new_line.push_str(&line[end_idx..]);
*line = new_line;
// Re-populate the textarea.
let new_text = lines.join("\n");
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(new_text);
// Note: tui-textarea currently exposes only relative cursor
// movements. Leaving the cursor position unchanged is acceptable
// as subsequent typing will move the cursor naturally.
}
} }
/// Handle key event when no popup is visible. /// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let input: Input = key_event.into(); match key_event {
match input {
// ------------------------------------------------------------- // -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not // History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid // empty or when the cursor is at the correct position, to avoid
// interfering with normal cursor movement. // interfering with normal cursor movement.
// ------------------------------------------------------------- // -------------------------------------------------------------
Input { key: Key::Up, .. } => { KeyEvent {
if self.history.should_handle_navigation(&self.textarea) { code: KeyCode::Up | KeyCode::Down,
let consumed = self ..
.history
.navigate_up(&mut self.textarea, &self.app_event_tx);
if consumed {
return (InputResult::None, true);
}
}
self.handle_input_basic(input)
}
Input { key: Key::Down, .. } => {
if self.history.should_handle_navigation(&self.textarea) {
let consumed = self
.history
.navigate_down(&mut self.textarea, &self.app_event_tx);
if consumed {
return (InputResult::None, true);
}
}
self.handle_input_basic(input)
}
Input {
key: Key::Enter,
shift: false,
alt: false,
ctrl: false,
} => { } => {
let mut text = self.textarea.lines().join("\n"); if self
self.textarea.select_all(); .history
self.textarea.cut(); .should_handle_navigation(self.textarea.text(), self.textarea.cursor())
{
let replace_text = match key_event.code {
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
_ => unreachable!(),
};
if let Some(text) = replace_text {
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
return (InputResult::None, true);
}
}
self.handle_input_basic(key_event)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
// Replace all pending pastes in the text // Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes { for (placeholder, actual) in &self.pending_pastes {
@@ -467,41 +507,15 @@ impl ChatComposer<'_> {
(InputResult::Submitted(text), true) (InputResult::Submitted(text), true)
} }
} }
Input {
key: Key::Enter, ..
}
| Input {
key: Key::Char('j'),
ctrl: true,
alt: false,
shift: false,
} => {
self.textarea.insert_newline();
(InputResult::None, true)
}
Input {
key: Key::Char('d'),
ctrl: true,
alt: false,
shift: false,
} => {
self.textarea.input(Input {
key: Key::Delete,
ctrl: false,
alt: false,
shift: false,
});
(InputResult::None, true)
}
input => self.handle_input_basic(input), input => self.handle_input_basic(input),
} }
} }
/// Handle generic Input events that modify the textarea content. /// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) { fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Special handling for backspace on placeholders // Special handling for backspace on placeholders
if let Input { if let KeyEvent {
key: Key::Backspace, code: KeyCode::Backspace,
.. ..
} = input } = input
{ {
@@ -510,20 +524,9 @@ impl ChatComposer<'_> {
} }
} }
if let Input {
key: Key::Char('u'),
ctrl: true,
alt: false,
..
} = input
{
self.textarea.delete_line_by_head();
return (InputResult::None, true);
}
// Normal input handling // Normal input handling
self.textarea.input(input); self.textarea.input(input);
let text_after = self.textarea.lines().join("\n"); let text_after = self.textarea.text();
// Check if any placeholders were removed and remove their corresponding pending pastes // Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes self.pending_pastes
@@ -535,21 +538,16 @@ impl ChatComposer<'_> {
/// Attempts to remove a placeholder if the cursor is at the end of one. /// Attempts to remove a placeholder if the cursor is at the end of one.
/// Returns true if a placeholder was removed. /// Returns true if a placeholder was removed.
fn try_remove_placeholder_at_cursor(&mut self) -> bool { fn try_remove_placeholder_at_cursor(&mut self) -> bool {
let (row, col) = self.textarea.cursor(); let p = self.textarea.cursor();
let line = self let text = self.textarea.text();
.textarea
.lines()
.get(row)
.map(|s| s.as_str())
.unwrap_or("");
// Find any placeholder that ends at the cursor position // Find any placeholder that ends at the cursor position
let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| { let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
if col < ph.len() { if p < ph.len() {
return None; return None;
} }
let potential_ph_start = col - ph.len(); let potential_ph_start = p - ph.len();
if line[potential_ph_start..col] == *ph { if text[potential_ph_start..p] == *ph {
Some(ph.clone()) Some(ph.clone())
} else { } else {
None None
@@ -557,17 +555,7 @@ impl ChatComposer<'_> {
}); });
if let Some(placeholder) = placeholder_to_remove { if let Some(placeholder) = placeholder_to_remove {
// Remove the entire placeholder from the text self.textarea.replace_range(p - placeholder.len()..p, "");
let placeholder_len = placeholder.len();
for _ in 0..placeholder_len {
self.textarea.input(Input {
key: Key::Backspace,
ctrl: false,
alt: false,
shift: false,
});
}
// Remove from pending pastes
self.pending_pastes.retain(|(ph, _)| ph != &placeholder); self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
true true
} else { } else {
@@ -579,16 +567,7 @@ impl ChatComposer<'_> {
/// textarea. This must be called after every modification that can change /// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate. /// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self) { fn sync_command_popup(&mut self) {
// Inspect only the first line to decide whether to show the popup. In let first_line = self.textarea.text().lines().next().unwrap_or("");
// the common case (no leading slash) we avoid copying the entire
// textarea contents.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/'); let input_starts_with_slash = first_line.starts_with('/');
match &mut self.active_popup { match &mut self.active_popup {
ActivePopup::Command(popup) => { ActivePopup::Command(popup) => {
@@ -644,74 +623,29 @@ impl ChatComposer<'_> {
self.dismissed_file_popup_token = None; self.dismissed_file_popup_token = None;
} }
fn update_border(&mut self, has_focus: bool) { fn set_has_focus(&mut self, has_focus: bool) {
let border_style = if has_focus { self.has_focus = has_focus;
Style::default().fg(Color::Cyan)
} else {
Style::default().dim()
};
self.textarea.set_block(
ratatui::widgets::Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(border_style),
);
} }
} }
impl WidgetRef for &ChatComposer<'_> { impl WidgetRef for &ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
ActivePopup::File(popup) => popup.calculate_required_height(),
ActivePopup::None => 1,
};
let [textarea_rect, popup_rect] =
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
match &self.active_popup { match &self.active_popup {
ActivePopup::Command(popup) => { ActivePopup::Command(popup) => {
let popup_height = popup.calculate_required_height(); popup.render_ref(popup_rect, buf);
// Split the provided rect so that the popup is rendered at the
// **bottom** and the textarea occupies the remaining space above.
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
} }
ActivePopup::File(popup) => { ActivePopup::File(popup) => {
let popup_height = popup.calculate_required_height(); popup.render_ref(popup_rect, buf);
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
} }
ActivePopup::None => { ActivePopup::None => {
let mut textarea_rect = area; let bottom_line_rect = popup_rect;
textarea_rect.height = textarea_rect.height.saturating_sub(1);
self.textarea.render(textarea_rect, buf);
let mut bottom_line_rect = area;
bottom_line_rect.y += textarea_rect.height;
bottom_line_rect.height = 1;
let key_hint_style = Style::default().fg(Color::Cyan); let key_hint_style = Style::default().fg(Color::Cyan);
let hint = if self.ctrl_c_quit_hint { let hint = if self.ctrl_c_quit_hint {
vec![ vec![
@@ -740,6 +674,56 @@ impl WidgetRef for &ChatComposer<'_> {
.render_ref(bottom_line_rect, buf); .render_ref(bottom_line_rect, buf);
} }
} }
Block::default()
.border_style(Style::default().dim())
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(if self.has_focus {
Color::Cyan
} else {
Color::Gray
}))
.render_ref(
Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height),
buf,
);
let mut textarea_rect = textarea_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(1);
textarea_rect.x += 1;
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 placeholder = if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.token_usage;
let model_context_window = token_usage_info.model_context_window;
match (token_usage.total_tokens, model_context_window) {
(total_tokens, Some(context_window)) => {
let percent_remaining: u8 = if context_window > 0 {
// Calculate the percentage of context left.
let percent =
100.0 - (total_tokens as f32 / context_window as f32 * 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
// If we don't have a context window, we cannot compute the
// percentage.
100
};
// When https://github.com/openai/codex/issues/1257 is resolved,
// check if `percent_remaining < 25`, and if so, recommend
// /compact.
format!("{BASE_PLACEHOLDER_TEXT}{percent_remaining}% context left")
}
(total_tokens, None) => {
format!("{BASE_PLACEHOLDER_TEXT}{total_tokens} tokens used")
}
}
} else {
BASE_PLACEHOLDER_TEXT.to_string()
};
Line::from(placeholder)
.style(Style::default().dim())
.render_ref(textarea_rect.inner(Margin::new(1, 0)), buf);
}
} }
} }
@@ -749,7 +733,7 @@ mod tests {
use crate::bottom_pane::ChatComposer; use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult; use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use tui_textarea::TextArea; use crate::bottom_pane::textarea::TextArea;
#[test] #[test]
fn test_current_at_token_basic_cases() { fn test_current_at_token_basic_cases() {
@@ -792,9 +776,9 @@ mod tests {
]; ];
for (input, cursor_pos, expected, description) in test_cases { for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::default(); let mut textarea = TextArea::new();
textarea.insert_str(input); textarea.insert_str(input);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos)); textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea); let result = ChatComposer::current_at_token(&textarea);
assert_eq!( assert_eq!(
@@ -826,9 +810,9 @@ mod tests {
]; ];
for (input, cursor_pos, expected, description) in test_cases { for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::default(); let mut textarea = TextArea::new();
textarea.insert_str(input); textarea.insert_str(input);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos)); textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea); let result = ChatComposer::current_at_token(&textarea);
assert_eq!( assert_eq!(
@@ -863,13 +847,13 @@ mod tests {
// Full-width space boundaries // Full-width space boundaries
( (
"test @İstanbul", "test @İstanbul",
6, 8,
Some("İstanbul".to_string()), Some("İstanbul".to_string()),
"@ token after full-width space", "@ token after full-width space",
), ),
( (
"@ЙЦУ @诶", "@ЙЦУ @诶",
6, 10,
Some("".to_string()), Some("".to_string()),
"Full-width space between Unicode tokens", "Full-width space between Unicode tokens",
), ),
@@ -883,9 +867,9 @@ mod tests {
]; ];
for (input, cursor_pos, expected, description) in test_cases { for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::default(); let mut textarea = TextArea::new();
textarea.insert_str(input); textarea.insert_str(input);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos)); textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea); let result = ChatComposer::current_at_token(&textarea);
assert_eq!( assert_eq!(
@@ -907,7 +891,7 @@ mod tests {
let needs_redraw = composer.handle_paste("hello".to_string()); let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw); assert!(needs_redraw);
assert_eq!(composer.textarea.lines(), ["hello"]); assert_eq!(composer.textarea.text(), "hello");
assert!(composer.pending_pastes.is_empty()); assert!(composer.pending_pastes.is_empty());
let (result, _) = let (result, _) =
@@ -932,7 +916,7 @@ mod tests {
let needs_redraw = composer.handle_paste(large.clone()); let needs_redraw = composer.handle_paste(large.clone());
assert!(needs_redraw); assert!(needs_redraw);
let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
assert_eq!(composer.textarea.lines(), [placeholder.as_str()]); assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(composer.pending_pastes.len(), 1); assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder); assert_eq!(composer.pending_pastes[0].0, placeholder);
assert_eq!(composer.pending_pastes[0].1, large); assert_eq!(composer.pending_pastes[0].1, large);
@@ -1008,7 +992,7 @@ mod tests {
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
// Move cursor to end and press backspace // Move cursor to end and press backspace
composer.textarea.move_cursor(tui_textarea::CursorMove::End); composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
} }
@@ -1123,7 +1107,7 @@ mod tests {
current_pos += content.len(); current_pos += content.len();
} }
( (
composer.textarea.lines().join("\n"), composer.textarea.text().to_string(),
composer.pending_pastes.len(), composer.pending_pastes.len(),
current_pos, current_pos,
) )
@@ -1134,25 +1118,18 @@ mod tests {
let mut deletion_states = vec![]; let mut deletion_states = vec![];
// First deletion // First deletion
composer composer.textarea.set_cursor(states[0].2);
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push(( deletion_states.push((
composer.textarea.lines().join("\n"), composer.textarea.text().to_string(),
composer.pending_pastes.len(), composer.pending_pastes.len(),
)); ));
// Second deletion // Second deletion
composer composer.textarea.set_cursor(composer.textarea.text().len());
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
0,
composer.textarea.lines().join("\n").len() as u16,
));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push(( deletion_states.push((
composer.textarea.lines().join("\n"), composer.textarea.text().to_string(),
composer.pending_pastes.len(), composer.pending_pastes.len(),
)); ));
@@ -1191,17 +1168,13 @@ mod tests {
composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone());
composer composer
.textarea .textarea
.move_cursor(tui_textarea::CursorMove::Jump( .set_cursor((placeholder.len() - pos_from_end) as usize);
0,
(placeholder.len() - pos_from_end) as u16,
));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = ( let result = (
composer.textarea.lines().join("\n").contains(&placeholder), composer.textarea.text().contains(&placeholder),
composer.pending_pastes.len(), composer.pending_pastes.len(),
); );
composer.textarea.select_all(); composer.textarea.set_text("");
composer.textarea.cut();
result result
}) })
.collect(); .collect();

View File

@@ -1,8 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use tui_textarea::CursorMove;
use tui_textarea::TextArea;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use codex_core::protocol::Op; use codex_core::protocol::Op;
@@ -67,59 +64,52 @@ impl ChatComposerHistory {
/// Should Up/Down key presses be interpreted as history navigation given /// Should Up/Down key presses be interpreted as history navigation given
/// the current content and cursor position of `textarea`? /// the current content and cursor position of `textarea`?
pub fn should_handle_navigation(&self, textarea: &TextArea) -> bool { pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
if self.history_entry_count == 0 && self.local_history.is_empty() { if self.history_entry_count == 0 && self.local_history.is_empty() {
return false; return false;
} }
if textarea.is_empty() { if text.is_empty() {
return true; return true;
} }
// Textarea is not empty only navigate when cursor is at start and // Textarea is not empty only navigate when cursor is at start and
// text matches last recalled history entry so regular editing is not // text matches last recalled history entry so regular editing is not
// hijacked. // hijacked.
let (row, col) = textarea.cursor(); if cursor != 0 {
if row != 0 || col != 0 {
return false; return false;
} }
let lines = textarea.lines(); matches!(&self.last_history_text, Some(prev) if prev == text)
matches!(&self.last_history_text, Some(prev) if prev == &lines.join("\n"))
} }
/// Handle <Up>. Returns true when the key was consumed and the caller /// Handle <Up>. Returns true when the key was consumed and the caller
/// should request a redraw. /// should request a redraw.
pub fn navigate_up(&mut self, textarea: &mut TextArea, app_event_tx: &AppEventSender) -> bool { pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
let total_entries = self.history_entry_count + self.local_history.len(); let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 { if total_entries == 0 {
return false; return None;
} }
let next_idx = match self.history_cursor { let next_idx = match self.history_cursor {
None => (total_entries as isize) - 1, None => (total_entries as isize) - 1,
Some(0) => return true, // already at oldest Some(0) => return None, // already at oldest
Some(idx) => idx - 1, Some(idx) => idx - 1,
}; };
self.history_cursor = Some(next_idx); self.history_cursor = Some(next_idx);
self.populate_history_at_index(next_idx as usize, textarea, app_event_tx); self.populate_history_at_index(next_idx as usize, app_event_tx)
true
} }
/// Handle <Down>. /// Handle <Down>.
pub fn navigate_down( pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
&mut self,
textarea: &mut TextArea,
app_event_tx: &AppEventSender,
) -> bool {
let total_entries = self.history_entry_count + self.local_history.len(); let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 { if total_entries == 0 {
return false; return None;
} }
let next_idx_opt = match self.history_cursor { let next_idx_opt = match self.history_cursor {
None => return false, // not browsing None => return None, // not browsing
Some(idx) if (idx as usize) + 1 >= total_entries => None, Some(idx) if (idx as usize) + 1 >= total_entries => None,
Some(idx) => Some(idx + 1), Some(idx) => Some(idx + 1),
}; };
@@ -127,16 +117,15 @@ impl ChatComposerHistory {
match next_idx_opt { match next_idx_opt {
Some(idx) => { Some(idx) => {
self.history_cursor = Some(idx); self.history_cursor = Some(idx);
self.populate_history_at_index(idx as usize, textarea, app_event_tx); self.populate_history_at_index(idx as usize, app_event_tx)
} }
None => { None => {
// Past newest clear and exit browsing mode. // Past newest clear and exit browsing mode.
self.history_cursor = None; self.history_cursor = None;
self.last_history_text = None; self.last_history_text = None;
self.replace_textarea_content(textarea, ""); Some(String::new())
} }
} }
true
} }
/// Integrate a GetHistoryEntryResponse event. /// Integrate a GetHistoryEntryResponse event.
@@ -145,19 +134,18 @@ impl ChatComposerHistory {
log_id: u64, log_id: u64,
offset: usize, offset: usize,
entry: Option<String>, entry: Option<String>,
textarea: &mut TextArea, ) -> Option<String> {
) -> bool {
if self.history_log_id != Some(log_id) { if self.history_log_id != Some(log_id) {
return false; return None;
} }
let Some(text) = entry else { return false }; let text = entry?;
self.fetched_history.insert(offset, text.clone()); self.fetched_history.insert(offset, text.clone());
if self.history_cursor == Some(offset as isize) { if self.history_cursor == Some(offset as isize) {
self.replace_textarea_content(textarea, &text); self.last_history_text = Some(text.clone());
return true; return Some(text);
} }
false None
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -167,21 +155,20 @@ impl ChatComposerHistory {
fn populate_history_at_index( fn populate_history_at_index(
&mut self, &mut self,
global_idx: usize, global_idx: usize,
textarea: &mut TextArea,
app_event_tx: &AppEventSender, app_event_tx: &AppEventSender,
) { ) -> Option<String> {
if global_idx >= self.history_entry_count { if global_idx >= self.history_entry_count {
// Local entry. // Local entry.
if let Some(text) = self if let Some(text) = self
.local_history .local_history
.get(global_idx - self.history_entry_count) .get(global_idx - self.history_entry_count)
{ {
let t = text.clone(); self.last_history_text = Some(text.clone());
self.replace_textarea_content(textarea, &t); return Some(text.clone());
} }
} else if let Some(text) = self.fetched_history.get(&global_idx) { } else if let Some(text) = self.fetched_history.get(&global_idx) {
let t = text.clone(); self.last_history_text = Some(text.clone());
self.replace_textarea_content(textarea, &t); return Some(text.clone());
} else if let Some(log_id) = self.history_log_id { } else if let Some(log_id) = self.history_log_id {
let op = Op::GetHistoryEntryRequest { let op = Op::GetHistoryEntryRequest {
offset: global_idx, offset: global_idx,
@@ -189,14 +176,7 @@ impl ChatComposerHistory {
}; };
app_event_tx.send(AppEvent::CodexOp(op)); app_event_tx.send(AppEvent::CodexOp(op));
} }
} None
fn replace_textarea_content(&mut self, textarea: &mut TextArea, text: &str) {
textarea.select_all();
textarea.cut();
let _ = textarea.insert_str(text);
textarea.move_cursor(CursorMove::Jump(0, 0));
self.last_history_text = Some(text.to_string());
} }
} }
@@ -217,11 +197,9 @@ mod tests {
// Pretend there are 3 persistent entries. // Pretend there are 3 persistent entries.
history.set_metadata(1, 3); history.set_metadata(1, 3);
let mut textarea = TextArea::default();
// First Up should request offset 2 (latest) and await async data. // First Up should request offset 2 (latest) and await async data.
assert!(history.should_handle_navigation(&textarea)); assert!(history.should_handle_navigation("", 0));
assert!(history.navigate_up(&mut textarea, &tx)); assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent"); let event = rx.try_recv().expect("expected AppEvent to be sent");
@@ -235,14 +213,15 @@ mod tests {
}, },
history_request1 history_request1
); );
assert_eq!(textarea.lines().join("\n"), ""); // still empty
// Inject the async response. // Inject the async response.
assert!(history.on_entry_response(1, 2, Some("latest".into()), &mut textarea)); assert_eq!(
assert_eq!(textarea.lines().join("\n"), "latest"); Some("latest".into()),
history.on_entry_response(1, 2, Some("latest".into()))
);
// Next Up should move to offset 1. // Next Up should move to offset 1.
assert!(history.navigate_up(&mut textarea, &tx)); assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify second CodexOp event for offset 1. // Verify second CodexOp event for offset 1.
let event2 = rx.try_recv().expect("expected second event"); let event2 = rx.try_recv().expect("expected second event");
@@ -257,7 +236,9 @@ mod tests {
history_request_2 history_request_2
); );
history.on_entry_response(1, 1, Some("older".into()), &mut textarea); assert_eq!(
assert_eq!(textarea.lines().join("\n"), "older"); Some("older".into()),
history.on_entry_response(1, 1, Some("older".into()))
);
} }
} }

View File

@@ -19,6 +19,7 @@ mod chat_composer_history;
mod command_popup; mod command_popup;
mod file_search_popup; mod file_search_popup;
mod status_indicator_view; mod status_indicator_view;
mod textarea;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent { pub(crate) enum CancellationEvent {
@@ -36,7 +37,7 @@ use status_indicator_view::StatusIndicatorView;
pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPane<'a> {
/// Composer is retained even when a BottomPaneView is displayed so the /// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed. /// input state is retained when the view is closed.
composer: ChatComposer<'a>, composer: ChatComposer,
/// If present, this is displayed instead of the `composer`. /// If present, this is displayed instead of the `composer`.
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>, active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
@@ -74,7 +75,19 @@ impl BottomPane<'_> {
self.active_view self.active_view
.as_ref() .as_ref()
.map(|v| v.desired_height(width)) .map(|v| v.desired_height(width))
.unwrap_or(self.composer.desired_height()) .unwrap_or(self.composer.desired_height(width))
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
// Hide the cursor whenever an overlay view is active (e.g. the
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() {
None
} else {
self.composer.cursor_pos(area)
}
} }
/// Forward a key event to the active view or the composer. /// Forward a key event to the active view or the composer.

File diff suppressed because it is too large Load Diff

View File

@@ -509,6 +509,10 @@ impl ChatWidget<'_> {
self.bottom_pane self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window); .set_token_usage(self.token_usage.clone(), self.config.model_context_window);
} }
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom_pane.cursor_pos(area)
}
} }
impl WidgetRef for &ChatWidget<'_> { impl WidgetRef for &ChatWidget<'_> {