use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComposerHistorySearch {
pre_search_input: String,
pre_search_cursor: usize,
query: String,
selected: usize,
}
impl ComposerHistorySearch {
fn new(pre_search_input: String, pre_search_cursor: usize) -> Self {
Self {
pre_search_input,
pre_search_cursor,
query: String::new(),
selected: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InputHistoryDraft {
input: String,
cursor: usize,
}
pub(crate) fn char_count(text: &str) -> usize {
text.chars().count()
}
pub(crate) fn byte_index_at_char(text: &str, char_index: usize) -> usize {
if char_index == 0 {
return 0;
}
text.char_indices()
.nth(char_index)
.map(|(idx, _)| idx)
.unwrap_or_else(|| text.len())
}
fn remove_char_range(text: &mut String, start_char: usize, end_char: usize) -> bool {
if start_char >= end_char {
return false;
}
let start = byte_index_at_char(text, start_char);
let end = byte_index_at_char(text, end_char);
if start >= end {
return false;
}
text.replace_range(start..end, "");
true
}
pub(crate) fn prev_grapheme_boundary(text: &str, char_index: usize) -> usize {
use unicode_segmentation::UnicodeSegmentation;
let mut acc = 0usize;
for g in text.graphemes(true) {
let next = acc + g.chars().count();
if next >= char_index {
return acc;
}
acc = next;
}
acc
}
pub(crate) fn next_grapheme_boundary(text: &str, char_index: usize) -> usize {
use unicode_segmentation::UnicodeSegmentation;
let mut acc = 0usize;
for g in text.graphemes(true) {
let next = acc + g.chars().count();
if next > char_index {
return next;
}
acc = next;
}
acc
}
fn normalize_paste_text(text: &str) -> String {
if text.contains('\r') {
text.replace("\r\n", "\n").replace('\r', "\n")
} else {
text.to_string()
}
}
fn strip_raw_mouse_report_runs(input: &str, cursor: usize) -> Option<(String, usize)> {
let (after_fragments, after_fragments_cursor, fragments_changed) =
strip_control_sequence_fragments(input, cursor);
let chars: Vec<char> = after_fragments.chars().collect();
let mut output = String::with_capacity(after_fragments.len());
let mut new_cursor = 0usize;
let mut changed = fragments_changed;
let mut index = 0usize;
while index < chars.len() {
if is_raw_mouse_report_run_char(chars[index]) {
let start = index;
while index < chars.len() && is_raw_mouse_report_run_char(chars[index]) {
index += 1;
}
let run = &chars[start..index];
if let Some(keep) = raw_mouse_report_keep_mask(run) {
changed = true;
for (offset, ch) in run.iter().copied().enumerate() {
if !keep[offset] {
continue;
}
if start + offset < cursor {
new_cursor += 1;
}
output.push(ch);
}
continue;
}
for (offset, ch) in run.iter().copied().enumerate() {
if start + offset < after_fragments_cursor {
new_cursor += 1;
}
output.push(ch);
}
continue;
}
if index < after_fragments_cursor {
new_cursor += 1;
}
output.push(chars[index]);
index += 1;
}
changed.then(|| {
let cursor = new_cursor.min(char_count(&output));
(output, cursor)
})
}
fn is_raw_mouse_report_run_char(ch: char) -> bool {
matches!(ch, '\x1b' | '[' | '<' | ';' | ':' | 'M' | 'm') || ch.is_ascii_digit()
}
fn looks_like_raw_mouse_report_run(run: &[char]) -> bool {
if run.len() < 5 {
return false;
}
let has_separator = run.iter().any(|ch| matches!(ch, ';' | ':'));
let terminators = run.iter().filter(|ch| matches!(ch, 'M' | 'm')).count();
if !has_separator || terminators == 0 {
return false;
}
has_sgr_mouse_marker(run) || terminators >= 2
}
fn has_sgr_mouse_marker(run: &[char]) -> bool {
run.windows(2).any(|window| window == ['[', '<'])
}
fn raw_mouse_report_keep_mask(run: &[char]) -> Option<Vec<bool>> {
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut index = 0usize;
while index < run.len() {
let (start, body_start) = if run[index] == '\x1b'
&& run.get(index + 1) == Some(&'[')
&& run.get(index + 2) == Some(&'<')
{
(index, index + 3)
} else if run[index] == '[' && run.get(index + 1) == Some(&'<') {
(index, index + 2)
} else {
index += 1;
continue;
};
let mut end = body_start;
let mut has_digit = false;
let mut has_separator = false;
let mut matched = false;
while end < run.len() {
match run[end] {
'0'..='9' => {
has_digit = true;
end += 1;
}
';' | ':' => {
has_separator = true;
end += 1;
}
'M' | 'm' if has_digit && has_separator => {
ranges.push((start, end + 1));
index = end + 1;
matched = true;
break;
}
_ => break,
}
}
if !matched {
index = index.saturating_add(1);
}
}
if ranges.is_empty() {
if looks_like_raw_mouse_report_run(run) {
return Some(vec![false; run.len()]);
}
return None;
}
ranges.sort_unstable_by_key(|(start, _)| *start);
let first_start = ranges[0].0;
let mut prefix_start = first_start;
while prefix_start > 0 && is_raw_mouse_report_fragment_char(run[prefix_start - 1]) {
prefix_start -= 1;
}
if prefix_start < first_start
&& looks_like_raw_mouse_report_fragment(&run[prefix_start..first_start])
{
ranges.push((prefix_start, first_start));
}
let last_end = ranges.iter().map(|(_, end)| *end).max().unwrap_or_default();
if last_end < run.len() && looks_like_raw_mouse_report_fragment(&run[last_end..]) {
ranges.push((last_end, run.len()));
}
ranges.sort_unstable_by_key(|(start, _)| *start);
let mut keep = vec![true; run.len()];
for (start, end) in ranges {
for slot in keep.iter_mut().take(end.min(run.len())).skip(start) {
*slot = false;
}
}
Some(keep)
}
fn is_raw_mouse_report_fragment_char(ch: char) -> bool {
matches!(ch, ';' | ':' | 'M' | 'm') || ch.is_ascii_digit()
}
fn looks_like_raw_mouse_report_fragment(run: &[char]) -> bool {
if run.len() < 4 {
return false;
}
run.iter().any(|ch| ch.is_ascii_digit())
&& run.iter().any(|ch| matches!(ch, ';' | ':'))
&& run.iter().any(|ch| matches!(ch, 'M' | 'm'))
}
fn strip_control_sequence_fragments(input: &str, cursor: usize) -> (String, usize, bool) {
let chars: Vec<char> = input.chars().collect();
let mut output = String::with_capacity(input.len());
let mut new_cursor = 0usize;
let mut changed = false;
let mut index = 0usize;
while index < chars.len() {
if let Some(end) = match_osc8_fragment(&chars, index) {
index = end;
changed = true;
continue;
}
if let Some(end) = match_kitty_csi_fragment(&chars, index) {
index = end;
changed = true;
continue;
}
if index < cursor {
new_cursor += 1;
}
output.push(chars[index]);
index += 1;
}
let cursor = new_cursor.min(char_count(&output));
(output, cursor, changed)
}
fn match_osc8_fragment(chars: &[char], start: usize) -> Option<usize> {
let body_start = if chars.get(start) == Some(&'\x1b')
&& chars.get(start + 1) == Some(&']')
&& chars.get(start + 2) == Some(&'8')
&& chars.get(start + 3) == Some(&';')
{
start + 4
} else if chars.get(start) == Some(&']')
&& chars.get(start + 1) == Some(&'8')
&& chars.get(start + 2) == Some(&';')
{
start + 3
} else {
return None;
};
let mut end = body_start;
while end < chars.len() {
let ch = chars[end];
if ch == '\x07' {
return Some(end + 1);
}
if ch == '\x1b' && chars.get(end + 1) == Some(&'\\') {
return Some(end + 2);
}
if ch == '\\' {
return Some(end + 1);
}
if ch == '\x1b' && chars.get(end + 1) == Some(&']') {
return Some(end);
}
if ch == ']' && chars.get(end + 1) == Some(&'8') && chars.get(end + 2) == Some(&';') {
return Some(end);
}
if ch.is_whitespace() {
return None;
}
end += 1;
}
Some(end)
}
fn match_kitty_csi_fragment(chars: &[char], start: usize) -> Option<usize> {
let after_csi = if chars.get(start) == Some(&'\x1b') && chars.get(start + 1) == Some(&'[') {
start + 2
} else if chars.get(start) == Some(&'[') {
start + 1
} else {
return None;
};
let priv_byte = chars.get(after_csi)?;
if !matches!(priv_byte, '?' | '>' | '<' | '=') {
return None;
}
let mut end = after_csi + 1;
let mut saw_param = false;
while end < chars.len() {
let ch = chars[end];
if ch.is_ascii_digit() || ch == ';' || ch == ':' {
saw_param = true;
end += 1;
continue;
}
return match ch {
'u' => Some(end + 1),
'h' | 'l' if saw_param => Some(end + 1),
_ => None,
};
}
None
}
pub(crate) const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000;
const MAX_COMPOSER_DISPLAY_CHARS: usize = 4_000;
const MAX_DRAFT_HISTORY: usize = 50;
impl ComposerState {
pub fn cursor_byte_index(&self) -> usize {
byte_index_at_char(&self.input, self.cursor_position)
}
fn auto_expand_oversized_paste(&mut self) {
if let Some(full) = self.oversized_paste_full_text.take() {
self.input = full;
self.cursor_position = self.cursor_position.min(char_count(&self.input));
}
}
pub fn composer_attachment_count(&self) -> usize {
crate::tui::file_mention::media_attachment_references(&self.input).len()
}
pub fn selected_composer_attachment_index(&self) -> Option<usize> {
let count = self.composer_attachment_count();
self.selected_attachment_index
.filter(|index| *index < count)
}
fn strip_raw_mouse_reports_from_input(&mut self) {
if let Some((input, cursor_position)) =
strip_raw_mouse_report_runs(&self.input, self.cursor_position)
{
self.input = input;
self.cursor_position = cursor_position;
}
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
let total = char_count(&self.input);
let anchor = self.selection_anchor?.min(total);
let cursor = self.cursor_position.min(total);
if anchor == cursor {
return None;
}
Some(if anchor < cursor {
(anchor, cursor)
} else {
(cursor, anchor)
})
}
pub fn selected_text(&self) -> String {
self.selection_range()
.map(|(s, e)| {
let sb = byte_index_at_char(&self.input, s);
let eb = byte_index_at_char(&self.input, e);
self.input[sb..eb].to_string()
})
.unwrap_or_default()
}
pub fn clear_selection(&mut self) {
self.selection_anchor = None;
}
#[must_use]
pub fn vim_is_normal_mode(&self) -> bool {
self.vim_enabled && self.vim_mode == VimMode::Normal
}
#[must_use]
pub fn vim_is_visual_mode(&self) -> bool {
self.vim_enabled && self.vim_mode == VimMode::Visual
}
pub fn stash_current_input_for_recovery(&mut self) {
self.auto_expand_oversized_paste();
let draft = self.input.clone();
if draft.trim().is_empty() {
self.clear_undo_buffer = None;
return;
}
self.clear_undo_buffer = Some(draft.clone());
self.remember_draft_for_recovery(draft);
}
fn remember_draft_for_recovery(&mut self, draft: String) {
if draft.trim().is_empty() {
return;
}
self.draft_history.retain(|existing| existing != &draft);
self.draft_history.push_back(draft);
while self.draft_history.len() > MAX_DRAFT_HISTORY {
let _ = self.draft_history.pop_front();
}
}
pub fn is_history_search_active(&self) -> bool {
self.composer_history_search.is_some()
}
pub fn history_search_query(&self) -> Option<&str> {
self.composer_history_search
.as_ref()
.map(|search| search.query.as_str())
}
pub fn history_search_selected_index(&self) -> usize {
self.composer_history_search
.as_ref()
.map_or(0, |search| search.selected)
}
pub fn composer_display_input(&self) -> &str {
self.history_search_query().unwrap_or(&self.input)
}
pub fn composer_display_cursor(&self) -> usize {
self.composer_history_search
.as_ref()
.map_or(self.cursor_position, |search| char_count(&search.query))
}
pub fn history_search_matches(&self) -> Vec<String> {
let Some(query) = self.history_search_query() else {
return Vec::new();
};
self.history_search_matches_for_query(query)
}
fn history_search_matches_for_query(&self, query: &str) -> Vec<String> {
let normalized_query = query.trim().to_lowercase();
let mut seen: HashSet<&str> = HashSet::new();
let mut matches = Vec::new();
for candidate in self
.draft_history
.iter()
.rev()
.chain(self.input_history.iter().rev())
{
if candidate.trim().is_empty() || !seen.insert(candidate.as_str()) {
continue;
}
if normalized_query.is_empty() || candidate.to_lowercase().contains(&normalized_query) {
matches.push(candidate.clone());
}
}
matches
}
fn clamp_history_search_selection(&mut self) {
let Some(search) = self.composer_history_search.as_ref() else {
return;
};
let selected = search.selected;
let query = search.query.clone();
let match_count = self.history_search_matches_for_query(&query).len();
if let Some(search) = self.composer_history_search.as_mut() {
search.selected = if match_count == 0 {
0
} else {
selected.min(match_count.saturating_sub(1))
};
}
}
pub fn history_up(&mut self) {
if self.input_history.is_empty() {
return;
}
if self.history_index.is_none() {
self.auto_expand_oversized_paste();
self.history_navigation_draft = Some(InputHistoryDraft {
input: self.input.clone(),
cursor: self.cursor_position,
});
}
let new_index = match self.history_index {
None => self.input_history.len().saturating_sub(1),
Some(i) => i.saturating_sub(1),
};
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
self.cursor_position = char_count(&self.input);
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
}
fn clear_input_history_navigation(&mut self) {
self.history_index = None;
self.history_navigation_draft = None;
}
}
impl App {
pub fn insert_str(&mut self, text: &str) {
if text.is_empty() {
return;
}
self.auto_expand_oversized_paste();
self.delete_selection();
self.selected_attachment_index = None;
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert_str(byte_index, text);
self.cursor_position = cursor + char_count(text);
self.strip_raw_mouse_reports_from_input();
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
pub fn insert_paste_text(&mut self, text: &str) {
if let Some(pending) = self.paste_burst.flush_before_modified_input() {
self.insert_str(&pending);
}
let normalized = normalize_paste_text(text);
if !normalized.is_empty() {
self.insert_str(&normalized);
}
self.paste_burst.clear_after_explicit_paste();
}
pub fn insert_media_attachment(&mut self, kind: &str, path: &Path, description: Option<&str>) {
let reference = media_attachment_reference(kind, path, description);
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
let needs_prefix_newline = self.input[..byte_index]
.chars()
.last()
.is_some_and(|ch| !ch.is_whitespace());
let needs_suffix_newline = self.input[byte_index..]
.chars()
.next()
.is_some_and(|ch| !ch.is_whitespace());
let mut inserted = String::new();
if needs_prefix_newline {
inserted.push('\n');
}
inserted.push_str(&reference);
if needs_suffix_newline || self.input[byte_index..].is_empty() {
inserted.push('\n');
}
self.insert_str(&inserted);
self.paste_burst.clear_after_explicit_paste();
}
pub fn select_previous_composer_attachment(&mut self) -> bool {
let count = self.composer_attachment_count();
if count == 0 {
self.selected_attachment_index = None;
return false;
}
let next = self
.selected_composer_attachment_index()
.map_or(count.saturating_sub(1), |index| index.saturating_sub(1));
self.selected_attachment_index = Some(next);
self.cursor_position = 0;
self.status_message = Some("Attachment selected - Backspace/Delete removes it".to_string());
self.needs_redraw = true;
true
}
pub fn select_next_composer_attachment(&mut self) -> bool {
let count = self.composer_attachment_count();
let Some(index) = self.selected_composer_attachment_index() else {
return false;
};
if index + 1 < count {
self.selected_attachment_index = Some(index + 1);
self.status_message =
Some("Attachment selected - Backspace/Delete removes it".to_string());
} else {
self.selected_attachment_index = None;
self.status_message = Some("Composer focused".to_string());
}
self.needs_redraw = true;
true
}
pub fn clear_composer_attachment_selection(&mut self) -> bool {
if self.selected_attachment_index.take().is_some() {
self.status_message = Some("Composer focused".to_string());
self.needs_redraw = true;
true
} else {
false
}
}
pub fn remove_selected_composer_attachment(&mut self) -> bool {
let references = crate::tui::file_mention::media_attachment_references(&self.input);
let Some(index) = self
.selected_composer_attachment_index()
.filter(|index| *index < references.len())
else {
self.selected_attachment_index = None;
return false;
};
let reference = references[index].clone();
let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
let new_cursor_byte = if cursor_byte <= reference.start_byte {
cursor_byte
} else if cursor_byte >= reference.end_byte {
cursor_byte.saturating_sub(reference.end_byte - reference.start_byte)
} else {
reference.start_byte
};
self.input
.replace_range(reference.start_byte..reference.end_byte, "");
self.cursor_position = self.input[..new_cursor_byte.min(self.input.len())]
.chars()
.count();
let remaining = self.composer_attachment_count();
self.selected_attachment_index = if remaining == 0 {
None
} else {
Some(index.min(remaining.saturating_sub(1)))
};
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.status_message = Some(format!("Removed attachment: {}", reference.path));
self.needs_redraw = true;
true
}
pub fn flush_paste_burst_if_due(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(text) => {
self.insert_str(&text);
true
}
FlushResult::Typed(ch) => {
self.insert_char(ch);
true
}
FlushResult::None => false,
}
}
pub fn flush_paste_burst_if_enabled(&mut self, now: Instant) -> bool {
self.use_paste_burst_detection && self.flush_paste_burst_if_due(now)
}
pub fn paste_burst_next_flush_delay_if_enabled(&self, now: Instant) -> Option<Duration> {
if self.use_paste_burst_detection {
self.paste_burst.next_flush_delay(now)
} else {
None
}
}
pub fn flush_paste_burst_before_modified_input_if_enabled(&mut self) -> Option<String> {
if self.use_paste_burst_detection {
self.paste_burst.flush_before_modified_input()
} else {
None
}
}
pub fn paste_from_clipboard(&mut self) -> bool {
if self.clipboard.requires_terminal_paste() {
self.status_message = Some(self.tr(MessageId::ClipboardSshPasteHint).into_owned());
return false;
}
if let Some(content) = self.clipboard.read(self.workspace.as_path()) {
self.apply_clipboard_content(content);
return true;
}
false
}
pub fn apply_clipboard_content(&mut self, content: ClipboardContent) {
match content {
ClipboardContent::Text(text) => {
self.insert_paste_text(&text);
}
ClipboardContent::Image(pasted) => {
let description = format!("{} ({})", pasted.short_label(), pasted.size_label());
self.insert_media_attachment("image", &pasted.path, Some(&description));
self.status_message = Some(format!("Attached image: {description}"));
}
}
}
pub fn insert_char(&mut self, c: char) {
self.acknowledge_sticky_on_composer_activity();
self.clear_input_history_navigation();
self.auto_expand_oversized_paste();
self.delete_selection();
self.selected_attachment_index = None;
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert(byte_index, c);
self.cursor_position = cursor + 1;
self.strip_raw_mouse_reports_from_input();
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
pub fn delete_char(&mut self) {
self.clear_input_history_navigation();
self.auto_expand_oversized_paste();
if self.delete_selection() {
return;
}
self.selected_attachment_index = None;
if self.cursor_position == 0 {
return;
}
let cursor = self.cursor_position.min(char_count(&self.input));
let target = prev_grapheme_boundary(&self.input, cursor);
let removed = remove_char_range(&mut self.input, target, cursor);
if removed {
self.cursor_position = target;
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
}
pub fn delete_char_forward(&mut self) {
self.clear_input_history_navigation();
self.auto_expand_oversized_paste();
if self.delete_selection() {
return;
}
self.selected_attachment_index = None;
if self.input.is_empty() {
return;
}
let target = self.cursor_position;
let end = next_grapheme_boundary(&self.input, target);
let removed = remove_char_range(&mut self.input, target, end);
if !removed {
self.cursor_position = char_count(&self.input);
}
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
pub fn delete_word_backward(&mut self) {
self.clear_input_history_navigation();
if self.delete_selection() {
return;
}
self.selected_attachment_index = None;
if self.cursor_position == 0 {
return;
}
let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
let mut word_start = cursor_byte;
while word_start > 0 {
let Some((prev, ch)) = self.input[..word_start].char_indices().next_back() else {
break;
};
if !ch.is_whitespace() {
break;
}
word_start = prev;
}
while word_start > 0 {
let Some((prev, ch)) = self.input[..word_start].char_indices().next_back() else {
break;
};
if ch.is_whitespace() {
break;
}
word_start = prev;
}
if word_start < cursor_byte {
self.input.replace_range(word_start..cursor_byte, "");
self.cursor_position = char_count(&self.input[..word_start]);
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
}
pub fn delete_to_start_of_line(&mut self) {
self.clear_input_history_navigation();
if self.delete_selection() {
return;
}
self.selected_attachment_index = None;
if self.cursor_position == 0 {
return;
}
let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
let line_start = self.input[..cursor_byte]
.rfind('\n')
.map(|idx| idx + 1)
.unwrap_or(0);
if line_start < cursor_byte {
self.input.replace_range(line_start..cursor_byte, "");
self.cursor_position = char_count(&self.input[..line_start]);
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
}
pub fn delete_word_forward(&mut self) {
self.clear_input_history_navigation();
if self.delete_selection() {
return;
}
self.selected_attachment_index = None;
let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
if cursor_byte >= self.input.len() {
return;
}
let mut word_end = cursor_byte;
while word_end < self.input.len() {
let Some(ch) = self.input[word_end..].chars().next() else {
break;
};
if !ch.is_whitespace() {
break;
}
word_end += ch.len_utf8();
}
while word_end < self.input.len() {
let Some(ch) = self.input[word_end..].chars().next() else {
break;
};
if ch.is_whitespace() {
break;
}
word_end += ch.len_utf8();
}
if cursor_byte < word_end {
self.input.replace_range(cursor_byte..word_end, "");
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
}
pub fn kill_to_end_of_line(&mut self) -> bool {
self.clear_input_history_navigation();
if let Some((start, end)) = self.selection_range() {
let sb = byte_index_at_char(&self.input, start);
let eb = byte_index_at_char(&self.input, end);
self.kill_buffer = self.input[sb..eb].to_string();
self.delete_selection();
return true;
}
let total_chars = char_count(&self.input);
let cursor = self.cursor_position.min(total_chars);
let start_byte = byte_index_at_char(&self.input, cursor);
let eol_byte = self.input[start_byte..]
.find('\n')
.map(|rel| start_byte + rel)
.unwrap_or_else(|| self.input.len());
let end_byte = if start_byte == eol_byte {
if eol_byte < self.input.len() {
eol_byte + 1
} else {
return false;
}
} else {
eol_byte
};
let removed: String = self.input[start_byte..end_byte].to_string();
if removed.is_empty() {
return false;
}
self.kill_buffer = removed;
self.input.replace_range(start_byte..end_byte, "");
self.cursor_position = cursor;
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
true
}
pub fn yank(&mut self) -> bool {
if self.kill_buffer.is_empty() {
return false;
}
self.delete_selection();
self.clear_input_history_navigation();
let text = self.kill_buffer.clone();
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert_str(byte_index, &text);
self.cursor_position = cursor + char_count(&text);
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
true
}
pub fn move_cursor_left(&mut self) {
let cursor = self.cursor_position.min(char_count(&self.input));
self.cursor_position = prev_grapheme_boundary(&self.input, cursor);
self.needs_redraw = true;
}
pub fn move_cursor_right(&mut self) {
let total = char_count(&self.input);
if self.cursor_position < total {
self.cursor_position = next_grapheme_boundary(&self.input, self.cursor_position);
self.needs_redraw = true;
}
}
pub fn move_cursor_start(&mut self) {
self.cursor_position = 0;
self.needs_redraw = true;
}
pub fn move_cursor_end(&mut self) {
self.cursor_position = char_count(&self.input);
self.needs_redraw = true;
}
pub fn move_cursor_line_start(&mut self) {
let byte_pos = byte_index_at_char(&self.input, self.cursor_position);
let before = &self.input[..byte_pos];
if let Some(last_nl_byte) = before.rfind('\n') {
self.cursor_position = char_count(&self.input[..=last_nl_byte]);
} else {
self.cursor_position = 0;
}
self.needs_redraw = true;
}
pub fn move_cursor_line_end(&mut self) {
let search_start = byte_index_at_char(&self.input, self.cursor_position);
if let Some(offset) = self.input[search_start..].find('\n') {
self.cursor_position = char_count(&self.input[..search_start + offset]);
} else {
self.cursor_position = char_count(&self.input);
}
self.needs_redraw = true;
}
pub fn move_cursor_word_forward(&mut self) {
let text = self.input.clone();
let total = char_count(&text);
let mut pos = self.cursor_position;
if pos >= total {
return;
}
while pos < total {
let byte = byte_index_at_char(&text, pos);
let ch = text[byte..].chars().next().unwrap_or(' ');
if ch.is_whitespace() {
break;
}
pos += 1;
}
while pos < total {
let byte = byte_index_at_char(&text, pos);
let ch = text[byte..].chars().next().unwrap_or(' ');
if !ch.is_whitespace() {
break;
}
pos += 1;
}
self.cursor_position = pos;
self.needs_redraw = true;
}
pub fn move_cursor_word_backward(&mut self) {
let text = self.input.clone();
let mut pos = self.cursor_position;
if pos == 0 {
return;
}
pos -= 1;
while pos > 0 {
let byte = byte_index_at_char(&text, pos);
let ch = text[byte..].chars().next().unwrap_or(' ');
if !ch.is_whitespace() {
break;
}
pos -= 1;
}
while pos > 0 {
let byte = byte_index_at_char(&text, pos - 1);
let ch = text[byte..].chars().next().unwrap_or(' ');
if ch.is_whitespace() {
break;
}
pos -= 1;
}
self.cursor_position = pos;
self.needs_redraw = true;
}
pub fn select_all(&mut self) {
self.auto_expand_oversized_paste();
if self.input.is_empty() {
self.selection_anchor = None;
return;
}
self.selection_anchor = Some(0);
self.cursor_position = char_count(&self.input);
self.needs_redraw = true;
}
pub fn delete_selection(&mut self) -> bool {
let Some((start, end)) = self.selection_range() else {
return false;
};
if start == 0 && end == char_count(&self.input) {
let draft = self.input.clone();
if !draft.trim().is_empty() {
self.clear_undo_buffer = Some(draft.clone());
self.remember_draft_for_recovery(draft);
}
}
let sb = byte_index_at_char(&self.input, start);
let eb = byte_index_at_char(&self.input, end);
self.input.replace_range(sb..eb, "");
self.cursor_position = start;
self.selection_anchor = None;
self.clear_input_history_navigation();
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
true
}
pub fn vim_move_line_start(&mut self) {
let text = self.input.clone();
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
let line_start_byte = text[..cursor_byte].rfind('\n').map_or(0, |idx| idx + 1);
self.cursor_position = char_count(&text[..line_start_byte]);
self.needs_redraw = true;
}
pub fn vim_move_line_end(&mut self) {
let text = self.input.clone();
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
let line_end_char = text[cursor_byte..].find('\n').map_or_else(
|| char_count(&text),
|rel| char_count(&text[..cursor_byte + rel]),
);
self.cursor_position = line_end_char;
self.needs_redraw = true;
}
pub fn vim_move_word_forward(&mut self) {
self.move_cursor_word_forward();
}
pub fn vim_move_word_backward(&mut self) {
self.move_cursor_word_backward();
}
pub fn vim_delete_char_under_cursor(&mut self) {
self.auto_expand_oversized_paste();
let total = char_count(&self.input);
if self.cursor_position >= total {
return;
}
let pos = self.cursor_position;
let end = next_grapheme_boundary(&self.input, pos);
remove_char_range(&mut self.input, pos, end);
let new_total = char_count(&self.input);
if self.cursor_position > 0 && self.cursor_position >= new_total {
self.cursor_position = new_total.saturating_sub(1);
}
self.needs_redraw = true;
}
pub fn vim_delete_line(&mut self) {
let text = self.input.clone();
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
let line_start_byte = text[..cursor_byte].rfind('\n').map_or(0, |idx| idx + 1);
let line_end_byte = text[cursor_byte..]
.find('\n')
.map_or(text.len(), |rel| cursor_byte + rel);
let (remove_start, remove_end) = if line_end_byte < text.len() {
(line_start_byte, line_end_byte + 1)
} else if line_start_byte > 0 {
(line_start_byte - 1, line_end_byte)
} else {
(line_start_byte, line_end_byte)
};
self.input.replace_range(remove_start..remove_end, "");
self.cursor_position = char_count(&self.input[..remove_start]);
self.needs_redraw = true;
}
pub fn vim_enter_insert(&mut self) {
self.vim_mode = VimMode::Insert;
self.needs_redraw = true;
}
pub fn vim_enter_append(&mut self) {
let total = char_count(&self.input);
if self.cursor_position < total {
self.cursor_position += 1;
}
self.vim_mode = VimMode::Insert;
self.needs_redraw = true;
}
pub fn vim_open_line_below(&mut self) {
self.vim_move_line_end();
self.insert_char('\n');
self.vim_mode = VimMode::Insert;
}
pub fn vim_enter_normal(&mut self) {
self.vim_mode = VimMode::Normal;
self.vim_pending_d = false;
let total = char_count(&self.input);
if self.cursor_position > 0 && self.cursor_position >= total {
self.cursor_position = total.saturating_sub(1);
}
self.needs_redraw = true;
}
pub fn vim_move_down(&mut self) {
let text = self.input.clone();
let total = char_count(&text);
if self.cursor_position >= total {
self.history_down();
return;
}
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
let rest = &text[cursor_byte..];
if let Some(rel_nl) = rest.find('\n') {
let line_start_byte = text[..cursor_byte].rfind('\n').map_or(0, |i| i + 1);
let col = char_count(&text[line_start_byte..cursor_byte]);
let next_line_start = cursor_byte + rel_nl + 1;
let next_line = &text[next_line_start..];
let next_line_len = next_line.find('\n').unwrap_or(next_line.len());
let next_line_char_len =
char_count(&text[next_line_start..next_line_start + next_line_len]);
let target_col = col.min(next_line_char_len);
self.cursor_position = char_count(&text[..next_line_start]) + target_col;
self.needs_redraw = true;
} else {
self.history_down();
}
}
pub fn vim_move_up(&mut self) {
let text = self.input.clone();
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
if let Some(prev_nl) = text[..cursor_byte].rfind('\n') {
let line_start_byte = prev_nl + 1;
let col = char_count(&text[line_start_byte..cursor_byte]);
let prev_line_end = prev_nl; let prev_start = text[..prev_line_end].rfind('\n').map_or(0, |i| i + 1);
let prev_line_len = char_count(&text[prev_start..prev_line_end]);
let target_col = col.min(prev_line_len);
self.cursor_position = char_count(&text[..prev_start]) + target_col;
self.needs_redraw = true;
} else {
self.history_up();
}
}
pub fn clear_input(&mut self) {
self.clear_input_history_navigation();
self.input.clear();
self.cursor_position = 0;
self.pending_paste_reference = None;
self.oversized_paste_full_text = None;
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_selected = 0;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
self.needs_redraw = true;
}
pub fn clear_input_recoverable(&mut self) {
self.stash_current_input_for_recovery();
self.clear_input();
}
pub fn start_history_search(&mut self) {
if self.composer_history_search.is_some() {
return;
}
self.auto_expand_oversized_paste();
self.composer_history_search = Some(ComposerHistorySearch::new(
self.input.clone(),
self.cursor_position,
));
self.slash_menu_hidden = true;
self.mention_menu_hidden = true;
self.paste_burst.clear_after_explicit_paste();
self.status_message = Some("History search: type to filter, Enter accepts".to_string());
self.needs_redraw = true;
}
pub fn history_search_insert_char(&mut self, ch: char) {
if let Some(search) = self.composer_history_search.as_mut() {
search.query.push(ch);
search.selected = 0;
self.status_message = Some("History search: Enter accepts, Esc restores".to_string());
self.needs_redraw = true;
}
}
pub fn history_search_insert_str(&mut self, text: &str) {
if text.is_empty() {
return;
}
if let Some(search) = self.composer_history_search.as_mut() {
search.query.push_str(&normalize_paste_text(text));
search.selected = 0;
self.status_message = Some("History search: Enter accepts, Esc restores".to_string());
self.needs_redraw = true;
}
}
pub fn history_search_backspace(&mut self) {
if let Some(search) = self.composer_history_search.as_mut() {
search.query.pop();
search.selected = 0;
self.needs_redraw = true;
}
self.clamp_history_search_selection();
}
pub fn history_search_select_previous(&mut self) {
if let Some(search) = self.composer_history_search.as_mut() {
search.selected = search.selected.saturating_sub(1);
self.needs_redraw = true;
}
}
pub fn history_search_select_next(&mut self) {
let Some(search) = self.composer_history_search.as_ref() else {
return;
};
let query = search.query.clone();
let selected = search.selected;
let match_count = self.history_search_matches_for_query(&query).len();
if let Some(search) = self.composer_history_search.as_mut()
&& match_count > 0
{
search.selected = (selected + 1).min(match_count.saturating_sub(1));
self.needs_redraw = true;
}
}
pub fn accept_history_search(&mut self) -> bool {
let Some(search) = self.composer_history_search.take() else {
return false;
};
let matches = self.history_search_matches_for_query(&search.query);
if let Some(selected) = matches
.get(search.selected.min(matches.len().saturating_sub(1)))
.cloned()
{
self.input = selected;
self.cursor_position = char_count(&self.input);
self.history_index = None;
self.status_message = Some("History match inserted into composer".to_string());
self.needs_redraw = true;
true
} else {
self.composer_history_search = Some(search);
self.status_message = Some("No history matches".to_string());
self.needs_redraw = true;
false
}
}
pub fn cancel_history_search(&mut self) {
let Some(search) = self.composer_history_search.take() else {
return;
};
self.input = search.pre_search_input;
self.cursor_position = search.pre_search_cursor.min(char_count(&self.input));
self.status_message = Some("History search canceled".to_string());
self.needs_redraw = true;
}
pub fn submit_input(&mut self) -> Option<String> {
if self.input.trim().is_empty() {
self.paste_burst.clear_after_explicit_paste();
return None;
}
self.consolidate_large_input_if_oversized();
let mut input = self.input.clone();
if let Some(reference) = self.pending_paste_reference.take() {
self.oversized_paste_full_text = None;
input = reference;
} else if let Some(full) = self.oversized_paste_full_text.take() {
input = full;
}
if !looks_like_slash_command_input(&input) {
self.input_history.push(input.clone());
if self.max_input_history == 0 {
self.input_history.clear();
} else if self.input_history.len() > self.max_input_history {
let excess = self.input_history.len() - self.max_input_history;
self.input_history.drain(0..excess);
}
crate::composer_history::append_history(&input);
}
self.history_index = None;
self.history_navigation_draft = None;
self.clear_input();
self.work_surface.note_user_turn_or_new_operation();
Some(input)
}
pub fn restore_last_submitted_prompt_if_empty(&mut self) -> bool {
if !self.input.is_empty() {
return false;
}
let Some(prompt) = self
.last_submitted_prompt
.as_deref()
.filter(|prompt| !prompt.is_empty())
else {
return false;
};
self.input = prompt.to_string();
self.cursor_position = char_count(&self.input);
self.history_index = None;
self.history_navigation_draft = None;
self.selected_attachment_index = None;
self.needs_redraw = true;
true
}
pub fn restore_last_cleared_input_if_empty(&mut self) -> bool {
if !self.input.is_empty() {
return false;
}
let Some(saved) = self.clear_undo_buffer.take().filter(|s| !s.is_empty()) else {
return false;
};
self.input = saved;
self.cursor_position = char_count(&self.input);
self.history_index = None;
self.history_navigation_draft = None;
self.selected_attachment_index = None;
self.slash_menu_selected = 0;
self.slash_menu_hidden = false;
self.needs_redraw = true;
self.clear_undo_buffer = None;
true
}
pub fn handle_composer_enter(&mut self) -> Option<String> {
if self.use_paste_burst_detection {
let now = Instant::now();
if self
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
if !self.paste_burst.append_newline_if_active(now) {
self.insert_char('\n');
}
self.needs_redraw = true;
return None;
}
}
self.submit_input()
}
fn consolidate_large_input_if_oversized(&mut self) {
if char_count(&self.input) > MAX_SUBMITTED_INPUT_CHARS {
self.consolidate_large_input();
}
}
fn consolidate_large_input(&mut self) {
let full_input = std::mem::take(&mut self.input);
self.cursor_position = 0;
let now = chrono::Local::now();
let suffix = uuid::Uuid::new_v4().to_string()[..8].to_string();
let filename = format!("paste-{}-{}.md", now.format("%Y-%m-%d-%H%M%S"), suffix);
let rel_path = format!(".codewhale/pastes/{filename}");
let pastes_dir = self.workspace.join(".codewhale/pastes");
if let Err(e) = std::fs::create_dir_all(&pastes_dir) {
self.input = full_input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
self.cursor_position = char_count(&self.input);
self.push_status_toast(
format!("Failed to create paste directory: {e}"),
StatusToastLevel::Error,
Some(8_000),
);
return;
}
let file_path = self.workspace.join(&rel_path);
if let Err(e) = std::fs::write(&file_path, &full_input) {
self.input = full_input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
self.cursor_position = char_count(&self.input);
self.push_status_toast(
format!("Failed to write paste file: {e}"),
StatusToastLevel::Error,
Some(8_000),
);
return;
}
self.pending_paste_reference = Some(format!("@{rel_path}"));
self.oversized_paste_full_text = Some(full_input.clone());
let display_chars = char_count(&full_input).min(MAX_COMPOSER_DISPLAY_CHARS);
let mut truncated: String = full_input.chars().take(display_chars).collect();
if char_count(&full_input) > MAX_COMPOSER_DISPLAY_CHARS {
truncated.push_str("\n\n---\n(content truncated for display — start typing to expand; full text sent to model)");
}
self.input = truncated;
self.cursor_position = 0;
self.push_status_toast(
"Large paste backed up to file — the model will receive the full content.",
StatusToastLevel::Info,
Some(5_000),
);
}
pub fn history_down(&mut self) {
if self.input_history.is_empty() {
return;
}
match self.history_index {
None => {}
Some(i) => {
if i + 1 < self.input_history.len() {
self.history_index = Some(i + 1);
self.input = self.input_history[i + 1].clone();
self.cursor_position = char_count(&self.input);
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
} else {
self.history_index = None;
if let Some(draft) = self.history_navigation_draft.take() {
self.input = draft.input;
self.cursor_position = draft.cursor.min(char_count(&self.input));
self.selection_anchor = None;
self.selected_attachment_index = None;
self.slash_menu_hidden = false;
self.paste_burst.clear_after_explicit_paste();
self.needs_redraw = true;
} else {
self.clear_input();
}
}
}
}
}
}