mod cursor;
mod deletion;
mod history;
mod normalize;
mod paste;
use deletion::BoundaryBias;
use deletion::DeleteOperation;
use history::PromptSnapshot;
use history::SemanticHistory;
use paste::PasteAtom;
use paste::expand_visible_range_with_atoms;
use cursor::byte_offset_for_char_column;
use cursor::byte_offset_for_cursor;
pub(crate) use cursor::clamp_char_boundary;
use cursor::cursor_for_byte_offset;
pub(crate) use normalize::PromptTextNormalizer;
use normalize::normalize_prompt_text;
pub(crate) use normalize::normalize_prompt_text_bounded;
use normalize::normalized_cursor_offset;
use ratatui::{
Frame,
layout::{Position, Rect},
style::Style,
};
use std::{cell::RefCell, fmt, sync::Arc};
use tui_textarea::{CursorMove, CursorRenderMode, TextArea, WrapMode};
pub(crate) const MAX_PROMPT_BYTES: usize = 1_048_576;
pub(crate) const MAX_VISIBLE_ROWS: u16 = 5;
pub(crate) const COLLAPSED_PASTE_MIN_LINES: usize = 3;
pub(crate) const COLLAPSED_PASTE_BYTE_THRESHOLD: usize = 800;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptEditOutcome {
Changed,
Unchanged,
Failed,
RejectedTooLarge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptMovementOutcome {
Moved,
Unchanged,
Failed,
}
pub(crate) const PROMPT_MOVEMENT_FAILED_STATUS: &str =
"Prompt editor could not reach that text position";
fn movement_outcome(result: Result<usize, ()>) -> PromptMovementOutcome {
match result {
Ok(0) => PromptMovementOutcome::Unchanged,
Ok(_) => PromptMovementOutcome::Moved,
Err(()) => PromptMovementOutcome::Failed,
}
}
pub(crate) type PromptEditResult = PromptEditOutcome;
pub(crate) const PROMPT_TOO_LARGE_STATUS: &str = "Prompt is limited to 1 MiB; edit rejected";
pub(crate) struct PromptEditor {
textarea: RefCell<TextArea<'static>>,
cursor_visible: bool,
pending_submission: Option<PendingSubmission>,
paste_atoms: Vec<PasteAtom>,
next_paste_id: u64,
semantic_history: SemanticHistory,
semantic_history_active: bool,
content_revision: u64,
}
struct PendingSubmission {
textarea: TextArea<'static>,
state: PromptSnapshot,
history: SemanticHistory,
semantic_history_active: bool,
}
impl fmt::Debug for PromptEditor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PromptEditor")
.field("visible_text", &self.visible_text())
.field("cursor", &self.cursor())
.field("cursor_visible", &self.cursor_visible)
.field("content_revision", &self.content_revision)
.finish()
}
}
impl Default for PromptEditor {
fn default() -> Self {
Self::new("", 0)
}
}
impl PromptEditor {
pub(crate) fn new(text: &str, cursor: usize) -> Self {
let mut editor = Self::empty();
if let Ok(normalized) = normalize_prompt_text_bounded(text) {
let cursor = normalized_cursor_offset(text, cursor, &normalized);
editor.set_canonical_text(&normalized, cursor);
}
editor
}
pub(crate) fn from_canonical(text: &str, cursor: usize) -> Self {
debug_assert!(text.len() <= MAX_PROMPT_BYTES);
let mut editor = Self::empty();
editor.set_canonical_text(text, cursor);
editor
}
fn empty() -> Self {
let textarea = configured_textarea();
let max_edits = textarea.max_histories();
let mut editor = Self {
textarea: RefCell::new(textarea),
cursor_visible: true,
pending_submission: None,
paste_atoms: Vec::new(),
next_paste_id: 1,
semantic_history: SemanticHistory::new(
max_edits,
PromptSnapshot {
visible: String::new(),
atoms: Vec::new(),
next_paste_id: 1,
cursor: 0,
},
),
semantic_history_active: false,
content_revision: 0,
};
editor.refresh_atomic_ranges();
editor
}
fn set_canonical_text(&mut self, text: &str, cursor: usize) {
let cursor = clamp_char_boundary(text, cursor).min(text.len());
self.textarea
.borrow_mut()
.set_lines(split_lines(text), cursor_for_byte_offset(text, cursor));
self.paste_atoms.clear();
self.next_paste_id = 1;
self.semantic_history_active = false;
self.refresh_atomic_ranges();
self.semantic_history.reset(self.semantic_snapshot());
}
pub(crate) fn visible_text(&self) -> String {
self.textarea.borrow().lines().join("\n")
}
pub(crate) fn visible_cursor(&self) -> usize {
let textarea = self.textarea.borrow();
byte_offset_for_cursor(textarea.lines(), textarea.cursor())
}
pub(crate) fn text(&self) -> String {
let visible = self.visible_text();
expand_visible_range_with_atoms(&visible, &self.paste_atoms, 0, visible.len())
}
pub(crate) fn is_empty(&self) -> bool {
self.visible_text().is_empty()
}
pub(crate) fn cursor(&self) -> usize {
let visible = self.visible_cursor();
self.expanded_offset_for_visible_offset(visible, BoundaryBias::Forward)
}
pub(crate) fn set_cursor_visible(&mut self, visible: bool) {
self.cursor_visible = visible;
}
pub(crate) fn toggle_cursor(&mut self) {
if self.is_empty() {
self.cursor_visible = !self.cursor_visible;
} else {
self.cursor_visible = true;
}
}
pub(crate) fn content_revision(&self) -> u64 {
self.content_revision
}
pub(crate) fn starts_with_bang(&self) -> bool {
self.text().starts_with('!')
}
fn content_equals(&self, text: &str) -> bool {
self.text() == text
}
fn bump_content_revision(&mut self) {
self.content_revision = self.content_revision.wrapping_add(1);
}
pub(crate) fn set_text(&mut self, text: &str, cursor: usize) -> PromptEditResult {
let normalized = match normalize_prompt_text_bounded(text) {
Ok(normalized) => normalized,
Err(result) => return result,
};
let cursor = normalized_cursor_offset(text, cursor, &normalized);
let content_changed = !self.content_equals(&normalized);
let representation_changed =
!self.paste_atoms.is_empty() || self.visible_text() != normalized;
let current_cursor = self.cursor();
if !representation_changed && current_cursor == cursor {
return PromptEditResult::Unchanged;
}
self.set_canonical_text(&normalized, cursor);
if content_changed {
self.bump_content_revision();
}
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
}
pub(crate) fn clear(&mut self) -> PromptEditResult {
let content_changed = !self.text().is_empty();
if !content_changed && !self.selection_active() {
return PromptEditResult::Unchanged;
}
self.textarea
.borrow_mut()
.set_lines(vec![String::new()], (0, 0));
self.paste_atoms.clear();
self.next_paste_id = 1;
self.semantic_history_active = false;
self.refresh_atomic_ranges();
self.semantic_history.reset(self.semantic_snapshot());
if content_changed {
self.bump_content_revision();
}
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
}
pub(crate) fn insert_char(
&mut self,
ch: char,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
let replacement = normalize_prompt_text(&ch.to_string());
if replacement.is_empty() {
return PromptEditResult::Unchanged;
}
self.replace_active_selection(&replacement, &replacement, None)
}
pub(crate) fn backspace(&mut self, _visible_rows: u16, _wrap_width: u16) -> PromptEditResult {
self.edit(DeleteOperation::BackwardChar)
}
pub(crate) fn delete_forward(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
self.edit(DeleteOperation::ForwardChar)
}
pub(crate) fn move_left(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.move_character_left()
}
pub(crate) fn move_right(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.move_cursor(CursorMove::Forward)
}
pub(crate) fn move_up(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::Up);
}
pub(crate) fn move_down(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::Down);
}
pub(crate) fn move_word_left(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::WordBack);
}
pub(crate) fn move_word_right(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::WordForward);
}
pub(crate) fn extend_selection_left(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.extend_character_left()
}
pub(crate) fn extend_selection_right(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.extend_cursor(CursorMove::Forward)
}
pub(crate) fn extend_selection_up(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.extend_cursor(CursorMove::Up);
}
pub(crate) fn extend_selection_down(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.extend_cursor(CursorMove::Down);
}
pub(crate) fn delete_word(&mut self) -> PromptEditResult {
self.edit(DeleteOperation::BackwardWord)
}
pub(crate) fn delete_next_word(&mut self) -> PromptEditResult {
self.edit(DeleteOperation::ForwardWord)
}
pub(crate) fn select_all(&mut self) {
let mut textarea = self.textarea.borrow_mut();
textarea.cancel_selection();
textarea.move_cursor(CursorMove::Top);
textarea.move_cursor(CursorMove::Head);
textarea.start_selection();
textarea.move_cursor(CursorMove::Bottom);
textarea.move_cursor(CursorMove::End);
drop(textarea);
self.cursor_visible = true;
}
pub(crate) fn move_to_byte(
&mut self,
byte: usize,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
let byte = self.visible_offset_for_expanded_offset(byte, BoundaryBias::Forward);
let result = self.move_to_byte_inner(byte, false);
self.cursor_visible = true;
movement_outcome(result)
}
pub(crate) fn replace_visible_byte_range(
&mut self,
start: usize,
end: usize,
replacement: &str,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
let replacement = match normalize_prompt_text_bounded(replacement) {
Ok(replacement) => replacement,
Err(result) => return result,
};
self.replace_visible_byte_range_normalized(start, end, &replacement)
}
fn replace_visible_byte_range_normalized(
&mut self,
start: usize,
end: usize,
replacement: &str,
) -> PromptEditResult {
let visible = self.visible_text();
let start = clamp_char_boundary(&visible, start);
let end = clamp_char_boundary(&visible, end);
if start > end || (start == end && replacement.is_empty()) {
return PromptEditResult::Unchanged;
}
let (start, end) = self.expanded_visible_bounds(start, end);
let removed_len = self.expanded_visible_range(start, end).len();
let Some(current_len) = self.checked_expanded_text_len() else {
return PromptEditResult::RejectedTooLarge;
};
if !replacement_fits(current_len, removed_len, replacement.len()) {
return PromptEditResult::RejectedTooLarge;
}
let rollback = self.textarea.borrow().clone();
if self.move_to_byte_inner(start, false).is_err() {
*self.textarea.borrow_mut() = rollback;
return PromptEditResult::Failed;
}
self.textarea.borrow_mut().start_selection();
if self.move_to_byte_inner(end, true).is_err() {
*self.textarea.borrow_mut() = rollback;
return PromptEditResult::Failed;
}
self.replace_active_selection(replacement, replacement, None)
}
pub(crate) fn take_for_submit(&mut self) -> String {
let state = self.semantic_snapshot();
let history = self.semantic_history.clone();
let semantic_history_active = self.semantic_history_active;
let mut textarea = self.textarea.borrow_mut();
let submitted = std::mem::replace(&mut *textarea, configured_textarea());
drop(textarea);
let prompt =
expand_visible_range_with_atoms(&state.visible, &state.atoms, 0, state.visible.len());
let content_changed = !prompt.is_empty();
self.pending_submission = Some(PendingSubmission {
textarea: submitted,
state,
history,
semantic_history_active,
});
self.paste_atoms.clear();
self.next_paste_id = 1;
self.semantic_history_active = false;
self.semantic_history.reset(self.semantic_snapshot());
if content_changed {
self.bump_content_revision();
}
self.cursor_visible = true;
prompt
}
pub(crate) fn restore_rejected_submit(&mut self, _text: String) -> PromptEditResult {
if let Some(submitted) = self.pending_submission.take() {
let before = self.text();
*self.textarea.borrow_mut() = submitted.textarea;
self.paste_atoms = submitted.state.atoms;
self.next_paste_id = submitted.state.next_paste_id;
self.semantic_history = submitted.history;
self.semantic_history_active = submitted.semantic_history_active;
self.refresh_atomic_ranges();
if before != self.text() {
self.bump_content_revision();
}
self.cursor_visible = true;
PromptEditResult::Changed
} else {
self.set_text(&_text, _text.len())
}
}
pub(crate) fn commit_submission(&mut self) {
self.pending_submission = None;
}
pub(crate) fn selection_active(&self) -> bool {
self.textarea.borrow().is_selecting()
}
pub(crate) fn selection_byte_range(&self) -> Option<(usize, usize)> {
let (start, end) = self.selection_byte_bounds()?;
if start >= end {
return None;
}
let (start, end) = self.expanded_visible_bounds(start, end);
Some((
self.expanded_offset_for_visible_offset(start, BoundaryBias::Backward),
self.expanded_offset_for_visible_offset(end, BoundaryBias::Forward),
))
}
pub(crate) fn selection_text(&self) -> Option<String> {
let (start, end) = self.selection_byte_range()?;
self.text().get(start..end).map(ToOwned::to_owned)
}
fn selection_byte_bounds(&self) -> Option<(usize, usize)> {
let textarea = self.textarea.borrow();
let ((start_row, start_col), (end_row, end_col)) = textarea.selection_range()?;
Some((
byte_offset_for_cursor(textarea.lines(), (start_row, start_col)),
byte_offset_for_cursor(textarea.lines(), (end_row, end_col)),
))
}
fn selection_visible_bounds(&self) -> Option<(usize, usize)> {
let (start, end) = self.selection_byte_bounds()?;
Some(self.expanded_visible_bounds(start, end))
}
pub(crate) fn start_selection_at(&mut self, byte: usize) -> PromptMovementOutcome {
let byte = self.visible_offset_for_expanded_offset(byte, BoundaryBias::Backward);
let result = self.move_to_byte_inner(byte, false);
if result.is_err() {
return PromptMovementOutcome::Failed;
}
self.textarea.borrow_mut().start_selection();
movement_outcome(result)
}
pub(crate) fn update_selection_at(&mut self, byte: usize) -> PromptMovementOutcome {
if !self.selection_active() {
return PromptMovementOutcome::Unchanged;
}
let bias = if byte < self.cursor() {
BoundaryBias::Backward
} else {
BoundaryBias::Forward
};
let byte = self.visible_offset_for_expanded_offset(byte, bias);
movement_outcome(self.move_to_byte_inner(byte, true))
}
pub(crate) fn finish_selection(&mut self) -> Option<String> {
let selected = self.selection_text().filter(|text| !text.is_empty());
self.textarea.borrow_mut().cancel_selection();
selected
}
pub(crate) fn cancel_selection(&mut self) -> bool {
let active = self.selection_active();
self.textarea.borrow_mut().cancel_selection();
active
}
pub(crate) fn visible_rows(&self, wrap_width: u16) -> u16 {
self.visual_rows(wrap_width)
.min(usize::from(MAX_VISIBLE_ROWS))
.max(1) as u16
}
pub(crate) fn visual_rows(&self, wrap_width: u16) -> usize {
usize::from(
self.textarea
.borrow_mut()
.measure(textarea_width_for_wrap(wrap_width))
.content_rows,
)
}
pub(crate) fn cursor_at_position(&self, position: Position) -> Option<usize> {
let visible = {
let textarea = self.textarea.borrow();
textarea
.cursor_at_position(position)
.map(|cursor| byte_offset_for_cursor(textarea.lines(), cursor))
}?;
let bias = if self.selection_active() && visible < self.visible_cursor() {
BoundaryBias::Backward
} else {
BoundaryBias::Forward
};
Some(self.expanded_offset_for_visible_offset(visible, bias))
}
pub(crate) fn rendered_cursor_position(&self) -> Option<Position> {
self.textarea.borrow().rendered_cursor_position()
}
pub(crate) fn render_with_focus(
&self,
frame: &mut Frame<'_>,
area: Rect,
focused: bool,
theme: crate::tui::theme::MissionControlTheme,
) {
if area.is_empty() {
return;
}
let cursor_position = {
let mut textarea = self.textarea.borrow_mut();
textarea.set_style(theme.pane());
textarea.set_selection_style(theme.selection());
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
frame.render_widget(&*textarea, area);
textarea.rendered_cursor_position()
};
if focused
&& self.cursor_visible
&& let Some(position) = cursor_position
{
frame.set_cursor_position(position);
}
}
}
fn configured_textarea() -> TextArea<'static> {
let mut textarea = TextArea::default();
let theme = crate::tui::theme::MissionControlTheme::default();
textarea.set_style(theme.pane());
textarea.set_selection_style(theme.selection());
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
textarea
}
fn split_lines(text: &str) -> Vec<String> {
text.split('\n').map(str::to_owned).collect()
}
fn logical_line_count(text: &str) -> usize {
text.split('\n').count().max(1)
}
fn replacement_fits(current_len: usize, removed_len: usize, replacement_len: usize) -> bool {
current_len
.checked_sub(removed_len)
.and_then(|len| len.checked_add(replacement_len))
.is_some_and(|len| len <= MAX_PROMPT_BYTES)
}
fn textarea_width_for_wrap(wrap_width: u16) -> u16 {
wrap_width.saturating_add(1).max(1)
}