use std::collections::HashSet;
use std::time::{Duration, Instant};
use astrid_types::Topic;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum InputSegment {
Text(String),
PasteBlock {
raw: String,
line_count: usize,
},
}
#[derive(Debug, Clone, Default)]
pub(crate) struct InputBuffer {
pub segments: Vec<InputSegment>,
pub cursor: (usize, usize),
}
impl InputBuffer {
pub(crate) fn is_empty(&self) -> bool {
self.segments.is_empty()
|| self.segments.iter().all(|s| match s {
InputSegment::Text(t) => t.is_empty(),
InputSegment::PasteBlock { .. } => false,
})
}
pub(crate) fn clear(&mut self) {
self.segments.clear();
self.cursor = (0, 0);
}
pub(crate) fn flat_text(&self) -> String {
let mut out = String::new();
for seg in &self.segments {
match seg {
InputSegment::Text(t) => out.push_str(t),
InputSegment::PasteBlock { raw, .. } => out.push_str(raw),
}
}
out
}
pub(crate) fn starts_with_slash(&self) -> bool {
matches!(self.segments.first(), Some(InputSegment::Text(t)) if t.starts_with('/'))
}
pub(crate) fn text_for_palette(&self) -> &str {
match self.segments.first() {
Some(InputSegment::Text(t)) => t.as_str(),
_ => "",
}
}
pub(crate) fn has_paste_blocks(&self) -> bool {
self.segments
.iter()
.any(|s| matches!(s, InputSegment::PasteBlock { .. }))
}
pub(crate) fn paste_block_total_lines(&self) -> usize {
self.segments
.iter()
.map(|s| match s {
InputSegment::PasteBlock { line_count, .. } => *line_count,
InputSegment::Text(_) => 0,
})
.sum()
}
pub(crate) fn set_text(&mut self, s: String) {
let len = s.len();
self.segments.clear();
if !s.is_empty() {
self.segments.push(InputSegment::Text(s));
}
self.cursor = (0, len);
self.normalize();
}
pub(crate) fn take_flat(&mut self) -> Option<String> {
let text = self.flat_text();
self.clear();
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
pub(crate) fn insert_char(&mut self, c: char) {
self.ensure_text_at_cursor();
if let Some(InputSegment::Text(t)) = self.segments.get_mut(self.cursor.0) {
t.insert(self.cursor.1, c);
self.cursor.1 = self.cursor.1.saturating_add(c.len_utf8());
}
self.normalize();
}
pub(crate) fn insert_str(&mut self, s: &str) {
self.ensure_text_at_cursor();
if let Some(InputSegment::Text(t)) = self.segments.get_mut(self.cursor.0) {
t.insert_str(self.cursor.1, s);
self.cursor.1 = self.cursor.1.saturating_add(s.len());
}
self.normalize();
}
pub(crate) fn backspace(&mut self) {
if self.segments.is_empty() {
return;
}
let (seg_idx, byte_off) = self.cursor;
if let Some(InputSegment::Text(t)) = self.segments.get(seg_idx)
&& byte_off > 0
{
let prev = t[..byte_off]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
if let Some(InputSegment::Text(t)) = self.segments.get_mut(seg_idx) {
t.remove(prev);
}
self.cursor.1 = prev;
self.normalize();
return;
}
if seg_idx == 0 {
if matches!(self.segments.first(), Some(InputSegment::PasteBlock { .. })) {
self.segments.remove(0);
self.cursor = (0, 0);
self.normalize();
}
return;
}
if matches!(
self.segments.get(seg_idx),
Some(InputSegment::PasteBlock { .. })
) {
self.segments.remove(seg_idx);
self.cursor.0 = seg_idx.saturating_sub(1);
self.normalize();
return;
}
let prev_idx = seg_idx.saturating_sub(1);
match &self.segments[prev_idx] {
InputSegment::PasteBlock { .. } => {
self.segments.remove(prev_idx);
self.cursor.0 = prev_idx;
self.normalize();
},
InputSegment::Text(t) => {
if let Some((prev_char_idx, _)) = t.char_indices().next_back() {
if let Some(InputSegment::Text(t)) = self.segments.get_mut(prev_idx) {
t.remove(prev_char_idx);
}
self.cursor = (prev_idx, prev_char_idx);
self.normalize();
}
},
}
}
pub(crate) fn delete_forward(&mut self) {
if self.segments.is_empty() {
return;
}
let (seg_idx, byte_off) = self.cursor;
if let Some(seg) = self.segments.get(seg_idx) {
match seg {
InputSegment::Text(t) => {
if byte_off < t.len() {
if let Some(InputSegment::Text(t)) = self.segments.get_mut(seg_idx) {
t.remove(byte_off);
}
self.normalize();
return;
}
let next_idx = seg_idx.saturating_add(1);
if next_idx < self.segments.len()
&& matches!(self.segments[next_idx], InputSegment::PasteBlock { .. })
{
self.segments.remove(next_idx);
self.normalize();
} else if let Some(InputSegment::Text(next_t)) = self.segments.get(next_idx)
&& !next_t.is_empty()
{
let first_char_len = next_t.chars().next().map_or(0, char::len_utf8);
if let Some(InputSegment::Text(next_t)) = self.segments.get_mut(next_idx) {
next_t.drain(..first_char_len);
}
self.normalize();
}
},
InputSegment::PasteBlock { .. } => {
self.segments.remove(seg_idx);
self.normalize();
},
}
}
}
pub(crate) fn move_left(&mut self) {
if self.segments.is_empty() {
return;
}
let (seg_idx, byte_off) = self.cursor;
if let Some(InputSegment::Text(t)) = self.segments.get(seg_idx)
&& byte_off > 0
{
let prev = t[..byte_off]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
self.cursor.1 = prev;
return;
}
if seg_idx == 0 {
return; }
let prev_idx = seg_idx.saturating_sub(1);
match &self.segments[prev_idx] {
InputSegment::PasteBlock { .. } => {
if prev_idx == 0 {
self.segments.insert(0, InputSegment::Text(String::new()));
self.cursor = (0, 0);
} else {
let prev_prev = prev_idx.saturating_sub(1);
match &self.segments[prev_prev] {
InputSegment::Text(t) => self.cursor = (prev_prev, t.len()),
InputSegment::PasteBlock { .. } => {
self.segments
.insert(prev_idx, InputSegment::Text(String::new()));
self.cursor = (prev_idx, 0);
},
}
}
},
InputSegment::Text(t) => {
self.cursor = (prev_idx, t.len());
},
}
}
pub(crate) fn move_right(&mut self) {
if self.segments.is_empty() {
return;
}
let (seg_idx, byte_off) = self.cursor;
if let Some(seg) = self.segments.get(seg_idx) {
match seg {
InputSegment::Text(t) => {
if byte_off < t.len() {
let (_, c) = t[byte_off..]
.char_indices()
.next()
.expect("byte_off < len guarantees a char");
self.cursor.1 = byte_off.saturating_add(c.len_utf8());
return;
}
},
InputSegment::PasteBlock { .. } => {
},
}
let mut target = seg_idx.saturating_add(1);
while target < self.segments.len()
&& matches!(self.segments[target], InputSegment::PasteBlock { .. })
{
target = target.saturating_add(1);
}
if target < self.segments.len() {
self.cursor = (target, 0);
}
}
}
pub(crate) fn move_home(&mut self) {
if matches!(self.segments.first(), Some(InputSegment::PasteBlock { .. })) {
self.segments.insert(0, InputSegment::Text(String::new()));
}
self.cursor = (0, 0);
}
pub(crate) fn move_end(&mut self) {
if self.segments.is_empty() {
self.cursor = (0, 0);
return;
}
let last_idx = self.segments.len().saturating_sub(1);
let byte_off = match &self.segments[last_idx] {
InputSegment::Text(t) => t.len(),
InputSegment::PasteBlock { .. } => 0,
};
self.cursor = (last_idx, byte_off);
}
pub(crate) fn insert_paste(&mut self, content: String) {
let line_count = content.lines().count().max(1);
let block = InputSegment::PasteBlock {
raw: content,
line_count,
};
if self.segments.is_empty() {
self.segments.push(block);
self.cursor = (0, 0);
self.segments.push(InputSegment::Text(String::new()));
self.cursor = (1, 0);
self.normalize();
return;
}
let (seg_idx, byte_off) = self.cursor;
if seg_idx >= self.segments.len() {
self.segments.push(block);
let new_idx = self.segments.len().saturating_sub(1);
self.segments.push(InputSegment::Text(String::new()));
self.cursor = (new_idx.saturating_add(1), 0);
self.normalize();
return;
}
match &self.segments[seg_idx] {
InputSegment::Text(t) => {
if byte_off == 0 && t.is_empty() {
self.segments[seg_idx] = block;
self.segments
.insert(seg_idx.saturating_add(1), InputSegment::Text(String::new()));
self.cursor = (seg_idx.saturating_add(1), 0);
} else if byte_off == 0 {
self.segments.insert(seg_idx, block);
self.cursor = (seg_idx.saturating_add(1), 0);
} else if byte_off >= t.len() {
let insert_pos = seg_idx.saturating_add(1);
self.segments.insert(insert_pos, block);
let trailing_pos = insert_pos.saturating_add(1);
self.segments
.insert(trailing_pos, InputSegment::Text(String::new()));
self.cursor = (trailing_pos, 0);
} else {
let after = t[byte_off..].to_string();
if let Some(InputSegment::Text(t)) = self.segments.get_mut(seg_idx) {
t.truncate(byte_off);
}
let insert_pos = seg_idx.saturating_add(1);
self.segments.insert(insert_pos, block);
let trailing_pos = insert_pos.saturating_add(1);
self.segments
.insert(trailing_pos, InputSegment::Text(after));
self.cursor = (trailing_pos, 0);
}
},
InputSegment::PasteBlock { .. } => {
self.segments.insert(seg_idx, block);
let after_idx = seg_idx.saturating_add(1);
let orig_paste_idx = after_idx;
let needs_trailing = !matches!(
self.segments.get(orig_paste_idx.saturating_add(1)),
Some(InputSegment::Text(_))
);
if needs_trailing {
self.segments.insert(
orig_paste_idx.saturating_add(1),
InputSegment::Text(String::new()),
);
}
self.cursor = (orig_paste_idx.saturating_add(1), 0);
},
}
self.normalize();
}
fn ensure_text_at_cursor(&mut self) {
if self.segments.is_empty() {
self.segments.push(InputSegment::Text(String::new()));
self.cursor = (0, 0);
return;
}
if self.cursor.0 >= self.segments.len() {
self.segments.push(InputSegment::Text(String::new()));
self.cursor = (self.segments.len().saturating_sub(1), 0);
return;
}
if matches!(
self.segments[self.cursor.0],
InputSegment::PasteBlock { .. }
) {
let insert_idx = self.cursor.0.saturating_add(1);
self.segments
.insert(insert_idx, InputSegment::Text(String::new()));
self.cursor = (insert_idx, 0);
}
}
fn normalize(&mut self) {
let mut i: usize = 0;
while i.saturating_add(1) < self.segments.len() {
let next: usize = i.saturating_add(1);
if let (InputSegment::Text(_), InputSegment::Text(_)) =
(&self.segments[i], &self.segments[next])
{
let current_len = if let InputSegment::Text(t) = &self.segments[i] {
t.len()
} else {
unreachable!()
};
let removed = self.segments.remove(next);
let InputSegment::Text(merged_text) = removed else {
unreachable!()
};
if let InputSegment::Text(t) = &mut self.segments[i] {
t.push_str(&merged_text);
}
if self.cursor.0 == next {
self.cursor = (i, current_len.saturating_add(self.cursor.1));
} else if self.cursor.0 > next {
self.cursor.0 = self.cursor.0.saturating_sub(1);
}
} else {
i = i.saturating_add(1);
}
}
if self.segments.is_empty() {
self.cursor = (0, 0);
} else {
if self.cursor.0 >= self.segments.len() {
self.cursor.0 = self.segments.len().saturating_sub(1);
}
match &self.segments[self.cursor.0] {
InputSegment::Text(t) => {
if self.cursor.1 > t.len() {
self.cursor.1 = t.len();
}
},
InputSegment::PasteBlock { .. } => {
self.cursor.1 = 0;
},
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SlashCommandDef {
pub name: String,
pub description: String,
}
pub(crate) const PALETTE_MAX_VISIBLE: usize = 6;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum UiState {
Idle,
Thinking { start_time: Instant, dots: usize },
AwaitingApproval,
ToolRunning {
tool_name: String,
start_time: Instant,
},
Streaming { start_time: Instant },
Error { message: String },
Interrupted,
CopyMode,
Selection {
title: String,
options: Vec<astrid_types::ipc::SelectionOption>,
selected: usize,
scroll_offset: usize,
callback_topic: String,
request_id: String,
},
Onboarding {
capsule_id: String,
fields: Vec<astrid_types::ipc::OnboardingField>,
current_idx: usize,
answers: std::collections::HashMap<String, String>,
enum_selected: usize,
enum_scroll_offset: usize,
current_array_items: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum MessageRole {
User,
Assistant,
LocalUi,
}
#[derive(Debug, Clone, PartialEq)]
#[expect(dead_code)]
pub(crate) enum MessageKind {
DiffHeader,
DiffRemoved,
DiffAdded,
DiffFooter,
ToolResult(usize),
}
#[derive(Debug, Clone)]
pub(crate) struct Message {
pub role: MessageRole,
pub content: String,
#[expect(dead_code)]
pub timestamp: Instant,
pub kind: Option<MessageKind>,
pub spacing: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum NexusEntry {
Message(Message),
}
#[derive(Debug, Clone)]
pub(crate) struct ToolStatus {
pub id: String,
pub name: String,
pub display_arg: String,
pub status: ToolStatusKind,
pub start_time: Instant,
pub end_time: Option<Instant>,
pub output: Option<String>,
pub expanded: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[expect(dead_code)]
pub(crate) enum ToolStatusKind {
Pending,
Running,
Success,
Failed(String),
Denied,
}
#[derive(Debug, Clone)]
pub(crate) struct ApprovalRequest {
pub id: String,
pub tool_name: String,
pub description: String,
pub details: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub(crate) enum PendingAction {
Approve {
request_id: String,
decision: ApprovalDecisionKind,
},
Deny {
request_id: String,
reason: Option<String>,
},
SendInput(String),
CancelTurn,
SubmitOnboarding {
capsule_id: String,
answers: std::collections::HashMap<String, String>,
},
RefreshCommands,
SubmitSelection {
callback_topic: String,
request_id: String,
selected_id: String,
selected_label: String,
},
SubmitElicitResponse {
request_id: uuid::Uuid,
value: Option<String>,
values: Option<Vec<String>>,
},
HydrateSession,
}
#[derive(Debug, Clone)]
pub(crate) enum ApprovalDecisionKind {
Once,
Session,
Always,
}
pub(crate) struct App {
pub state: UiState,
pub should_quit: bool,
pub quit_pending: bool,
pub messages: Vec<Message>,
pub nexus_stream: Vec<NexusEntry>,
pub running_tools: Vec<ToolStatus>,
pub completed_tools: Vec<ToolStatus>,
pub pending_approvals: Vec<ApprovalRequest>,
pub selected_approval: usize,
pub input_buf: InputBuffer,
pub scroll_offset: usize,
pub slash_commands: Vec<SlashCommandDef>,
pub palette_selected: usize,
pub palette_scroll_offset: usize,
pub working_dir: String,
pub model_name: String,
pub context_usage: f32,
pub tokens_streamed: usize,
pub session_id_short: String,
pub terminal_height: u16,
pub last_completed: Option<(String, Duration)>,
pub last_completed_at: Option<Instant>,
pub stream_buffer: String,
pub pending_actions: Vec<PendingAction>,
pub copy_cursor: usize,
pub copy_selected: HashSet<usize>,
pub copy_notice: Option<(String, Instant)>,
pub status_message: Option<(String, Instant)>,
pub elicit_request_id: Option<uuid::Uuid>,
pub hydration_reply_topic: Option<Topic>,
}
impl App {
pub(crate) fn new(working_dir: String, model_name: String, session_id_short: String) -> Self {
Self {
state: UiState::Idle,
should_quit: false,
quit_pending: false,
messages: Vec::new(),
nexus_stream: Vec::new(),
running_tools: Vec::new(),
completed_tools: Vec::new(),
pending_approvals: Vec::new(),
selected_approval: 0,
input_buf: InputBuffer::default(),
scroll_offset: 0,
slash_commands: vec![
SlashCommandDef {
name: "/help".to_string(),
description: "Show available commands".to_string(),
},
SlashCommandDef {
name: "/clear".to_string(),
description: "Clear conversation history".to_string(),
},
SlashCommandDef {
name: "/install".to_string(),
description: "Install a capsule from a path or registry".to_string(),
},
SlashCommandDef {
name: "/refresh".to_string(),
description: "Reload all installed capsules into the OS".to_string(),
},
SlashCommandDef {
name: "/quit".to_string(),
description: "Disconnect from the daemon".to_string(),
},
],
palette_selected: 0,
palette_scroll_offset: 0,
working_dir,
model_name,
context_usage: 0.0,
tokens_streamed: 0,
session_id_short,
terminal_height: 24,
last_completed: None,
last_completed_at: None,
stream_buffer: String::new(),
pending_actions: Vec::new(),
copy_cursor: 0,
copy_selected: HashSet::new(),
copy_notice: None,
status_message: None,
elicit_request_id: None,
hydration_reply_topic: None,
}
}
pub(crate) fn palette_active(&self) -> bool {
matches!(self.state, UiState::Idle | UiState::Interrupted)
&& self.input_buf.starts_with_slash()
&& !self.input_buf.has_paste_blocks()
}
pub(crate) fn palette_filtered(&self) -> Vec<&SlashCommandDef> {
let prefix = self.input_buf.text_for_palette();
self.slash_commands
.iter()
.filter(|cmd| cmd.name.starts_with(prefix))
.collect()
}
pub(crate) fn palette_reset(&mut self) {
self.palette_selected = 0;
self.palette_scroll_offset = 0;
}
pub(crate) fn submit_input(&mut self) -> Option<String> {
self.input_buf.take_flat()
}
pub(crate) fn push_message(&mut self, role: MessageRole, content: String) {
let msg = Message {
role,
content,
timestamp: Instant::now(),
kind: None,
spacing: true,
};
self.nexus_stream.push(NexusEntry::Message(msg.clone()));
self.messages.push(msg);
}
pub(crate) fn push_notice(&mut self, text: &str) {
self.push_message(MessageRole::LocalUi, text.to_string());
}
pub(crate) fn approve_tool(&mut self, id: &str, decision: ApprovalDecisionKind) {
if let Some(pos) = self.pending_approvals.iter().position(|a| a.id == id) {
let approval = self.pending_approvals.remove(pos);
self.pending_actions.push(PendingAction::Approve {
request_id: id.to_string(),
decision,
});
if let Some(tool) = self.running_tools.last() {
self.state = UiState::ToolRunning {
tool_name: tool.name.clone(),
start_time: tool.start_time,
};
} else {
self.state = UiState::Thinking {
start_time: Instant::now(),
dots: 0,
};
}
let _ = approval; }
if self.pending_approvals.is_empty() && self.running_tools.is_empty() {
self.state = UiState::Thinking {
start_time: Instant::now(),
dots: 0,
};
} else if !self.pending_approvals.is_empty() {
self.state = UiState::AwaitingApproval;
}
}
pub(crate) fn deny_tool(&mut self, id: &str) {
self.pending_approvals.retain(|a| a.id != id);
self.pending_actions.push(PendingAction::Deny {
request_id: id.to_string(),
reason: None,
});
self.push_notice("Tool call denied.");
if self.pending_approvals.is_empty() {
self.state = UiState::Idle;
}
}
pub(crate) fn enter_copy_mode(&mut self) {
if self.nexus_stream.is_empty() {
return;
}
self.copy_cursor = self.nexus_stream.len().saturating_sub(1);
self.copy_selected.clear();
self.state = UiState::CopyMode;
self.scroll_offset = 0;
}
pub(crate) fn exit_copy_mode(&mut self) {
self.copy_selected.clear();
self.state = UiState::Idle;
}
pub(crate) fn toggle_copy_selection(&mut self) {
if !self.copy_selected.remove(&self.copy_cursor) {
self.copy_selected.insert(self.copy_cursor);
}
}
pub(crate) fn select_all_copy(&mut self) {
for i in 0..self.nexus_stream.len() {
self.copy_selected.insert(i);
}
}
pub(crate) fn collect_copy_text(&self) -> String {
let indices: Vec<usize> = if self.copy_selected.is_empty() {
vec![self.copy_cursor]
} else {
let mut v: Vec<usize> = self.copy_selected.iter().copied().collect();
v.sort_unstable();
v
};
let mut parts = Vec::new();
for idx in indices {
if let Some(entry) = self.nexus_stream.get(idx) {
match entry {
NexusEntry::Message(msg) => {
if let Some(MessageKind::ToolResult(tool_idx)) = &msg.kind {
if let Some(tool) = self.completed_tools.get(*tool_idx) {
let header = if tool.display_arg.is_empty() {
tool.name.clone()
} else {
format!("{}({})", tool.name, tool.display_arg)
};
if let Some(ref output) = tool.output {
parts.push(format!("{header}\n{output}"));
} else {
parts.push(header);
}
}
} else {
match msg.role {
MessageRole::User => {
parts.push(format!("> {}", msg.content));
},
MessageRole::Assistant | MessageRole::LocalUi => {
parts.push(msg.content.clone());
},
}
}
},
}
}
}
parts.join("\n\n")
}
pub(crate) fn copy_to_clipboard(&mut self) -> Result<(), String> {
let text = self.collect_copy_text();
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("Clipboard error: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("Clipboard error: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_buffer_empty_by_default() {
let buf = InputBuffer::default();
assert!(buf.is_empty());
assert_eq!(buf.flat_text(), "");
assert!(!buf.starts_with_slash());
assert!(!buf.has_paste_blocks());
}
#[test]
fn input_buffer_insert_char_basic() {
let mut buf = InputBuffer::default();
buf.insert_char('h');
buf.insert_char('i');
assert_eq!(buf.flat_text(), "hi");
assert!(!buf.is_empty());
assert_eq!(buf.cursor, (0, 2));
}
#[test]
fn input_buffer_insert_char_utf8() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_char('\u{00e9}'); buf.insert_char('b');
assert_eq!(buf.flat_text(), "a\u{00e9}b");
assert_eq!(buf.cursor.1, 4); }
#[test]
fn input_buffer_insert_str_basic() {
let mut buf = InputBuffer::default();
buf.insert_str("hello");
assert_eq!(buf.flat_text(), "hello");
assert_eq!(buf.cursor, (0, 5));
buf.cursor.1 = 2;
buf.insert_str("XY");
assert_eq!(buf.flat_text(), "heXYllo");
assert_eq!(buf.cursor, (0, 4));
}
#[test]
fn input_buffer_insert_str_utf8() {
let mut buf = InputBuffer::default();
buf.insert_str("a\u{00e9}b"); assert_eq!(buf.flat_text(), "a\u{00e9}b");
assert_eq!(buf.cursor, (0, 4)); }
#[test]
fn input_buffer_insert_str_adjacent_to_paste() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_str("after");
assert_eq!(buf.flat_text(), "A\nBafter");
}
#[test]
fn input_buffer_backspace_basic() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_char('b');
buf.insert_char('c');
buf.backspace();
assert_eq!(buf.flat_text(), "ab");
buf.backspace();
assert_eq!(buf.flat_text(), "a");
buf.backspace();
assert_eq!(buf.flat_text(), "");
buf.backspace();
assert_eq!(buf.flat_text(), "");
}
#[test]
fn input_buffer_backspace_utf8() {
let mut buf = InputBuffer::default();
buf.insert_char('\u{1f600}'); buf.insert_char('x');
assert_eq!(buf.flat_text(), "\u{1f600}x");
buf.backspace();
assert_eq!(buf.flat_text(), "\u{1f600}");
buf.backspace();
assert_eq!(buf.flat_text(), "");
}
#[test]
fn input_buffer_move_left_right() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_char('b');
buf.insert_char('c');
assert_eq!(buf.cursor, (0, 3));
buf.move_left();
assert_eq!(buf.cursor, (0, 2));
buf.move_left();
assert_eq!(buf.cursor, (0, 1));
buf.move_left();
assert_eq!(buf.cursor, (0, 0));
buf.move_left();
assert_eq!(buf.cursor, (0, 0));
buf.move_right();
assert_eq!(buf.cursor, (0, 1));
buf.move_right();
assert_eq!(buf.cursor, (0, 2));
buf.move_right();
assert_eq!(buf.cursor, (0, 3));
buf.move_right();
assert_eq!(buf.cursor, (0, 3));
}
#[test]
fn input_buffer_home_end() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_char('b');
buf.insert_char('c');
buf.move_home();
assert_eq!(buf.cursor, (0, 0));
buf.move_end();
assert_eq!(buf.cursor, (0, 3));
}
#[test]
fn input_buffer_insert_paste_empty_buffer() {
let mut buf = InputBuffer::default();
buf.insert_paste("line1\nline2\nline3".to_string());
assert!(buf.has_paste_blocks());
assert_eq!(buf.paste_block_total_lines(), 3);
assert_eq!(buf.flat_text(), "line1\nline2\nline3");
assert!(!buf.is_empty());
}
#[test]
fn input_buffer_insert_paste_splits_text() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_char('b');
buf.insert_char('c');
buf.insert_char('d');
buf.move_left();
buf.move_left();
assert_eq!(buf.cursor, (0, 2));
buf.insert_paste("X\nY".to_string());
assert_eq!(buf.flat_text(), "abX\nYcd");
assert!(buf.has_paste_blocks());
let text_segments: Vec<&str> = buf
.segments
.iter()
.filter_map(|s| match s {
InputSegment::Text(t) => Some(t.as_str()),
InputSegment::PasteBlock { .. } => None,
})
.collect();
assert!(text_segments.contains(&"ab"));
assert!(text_segments.contains(&"cd"));
}
#[test]
fn input_buffer_insert_paste_at_start() {
let mut buf = InputBuffer::default();
buf.insert_char('x');
buf.move_home();
buf.insert_paste("P\nQ".to_string());
assert_eq!(buf.flat_text(), "P\nQx");
}
#[test]
fn input_buffer_insert_paste_at_end() {
let mut buf = InputBuffer::default();
buf.insert_char('x');
buf.insert_paste("P\nQ".to_string());
assert_eq!(buf.flat_text(), "xP\nQ");
}
#[test]
fn input_buffer_backspace_deletes_paste_block() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_paste("X\nY".to_string());
buf.insert_char('b');
buf.backspace();
assert!(buf.flat_text().ends_with("X\nY"));
buf.backspace();
assert!(!buf.has_paste_blocks());
assert_eq!(buf.flat_text(), "a");
}
#[test]
fn input_buffer_backspace_paste_block_at_start() {
let mut buf = InputBuffer::default();
buf.insert_paste("block\ncontent".to_string());
buf.backspace();
assert!(!buf.has_paste_blocks());
assert!(buf.is_empty());
}
#[test]
fn input_buffer_backspace_non_leading_paste_block() {
let mut buf = InputBuffer::default();
buf.insert_char('x');
buf.insert_paste("A\nB".to_string());
buf.insert_char('y');
buf.backspace(); assert_eq!(buf.flat_text(), "xA\nB");
buf.backspace(); assert!(!buf.has_paste_blocks());
assert_eq!(buf.flat_text(), "x");
}
#[test]
fn input_buffer_delete_forward_paste_block() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_paste("X\nY".to_string());
buf.insert_char('b');
buf.move_home();
buf.move_right();
buf.delete_forward();
assert!(!buf.has_paste_blocks());
assert_eq!(buf.flat_text(), "ab");
}
#[test]
fn input_buffer_delete_forward_basic() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_char('b');
buf.insert_char('c');
buf.move_home();
buf.delete_forward();
assert_eq!(buf.flat_text(), "bc");
}
#[test]
fn input_buffer_cursor_skips_paste_block_left() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_paste("X\nY".to_string());
buf.insert_char('b');
buf.move_left();
buf.move_left();
let flat = buf.flat_text();
let a_pos = flat.find('a').unwrap();
assert_eq!(buf.cursor.1, a_pos.saturating_add(1));
}
#[test]
fn input_buffer_cursor_skips_paste_block_right() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_paste("X\nY".to_string());
buf.insert_char('b');
buf.move_home();
buf.move_right();
buf.move_right();
assert_eq!(buf.cursor.1, 0);
}
#[test]
fn input_buffer_flat_text_preserves_order() {
let mut buf = InputBuffer::default();
buf.insert_char('H');
buf.insert_char('i');
buf.insert_char(' ');
buf.insert_paste("code\nhere".to_string());
buf.insert_char(' ');
buf.insert_char('!');
assert_eq!(buf.flat_text(), "Hi code\nhere !");
}
#[test]
fn input_buffer_normalize_merges_adjacent_text() {
let mut buf = InputBuffer {
segments: vec![
InputSegment::Text("aa".to_string()),
InputSegment::Text("bb".to_string()),
],
cursor: (1, 1),
};
buf.normalize();
assert_eq!(buf.segments.len(), 1);
assert_eq!(buf.flat_text(), "aabb");
assert_eq!(buf.cursor, (0, 3));
}
#[test]
fn input_buffer_starts_with_slash() {
let mut buf = InputBuffer::default();
buf.insert_char('/');
buf.insert_char('h');
assert!(buf.starts_with_slash());
buf.clear();
buf.insert_char('h');
assert!(!buf.starts_with_slash());
}
#[test]
fn input_buffer_clear_resets() {
let mut buf = InputBuffer::default();
buf.insert_char('x');
buf.insert_paste("a\nb".to_string());
buf.clear();
assert!(buf.is_empty());
assert_eq!(buf.cursor, (0, 0));
assert!(buf.segments.is_empty());
}
#[test]
fn input_buffer_set_text() {
let mut buf = InputBuffer::default();
buf.insert_paste("old\nblock".to_string());
buf.set_text("/help ".to_string());
assert!(!buf.has_paste_blocks());
assert_eq!(buf.flat_text(), "/help ");
assert_eq!(buf.cursor, (0, 6));
}
#[test]
fn input_buffer_take_flat_returns_none_on_empty() {
let mut buf = InputBuffer::default();
assert_eq!(buf.take_flat(), None);
}
#[test]
fn input_buffer_take_flat_returns_none_on_whitespace() {
let mut buf = InputBuffer::default();
buf.insert_char(' ');
buf.insert_char(' ');
assert_eq!(buf.take_flat(), None);
}
#[test]
fn input_buffer_take_flat_returns_content() {
let mut buf = InputBuffer::default();
buf.insert_char('h');
buf.insert_char('i');
let result = buf.take_flat();
assert_eq!(result, Some("hi".to_string()));
assert!(buf.is_empty());
}
#[test]
fn input_buffer_text_for_palette() {
let mut buf = InputBuffer::default();
buf.insert_char('/');
buf.insert_char('h');
assert_eq!(buf.text_for_palette(), "/h");
buf.clear();
assert_eq!(buf.text_for_palette(), "");
}
#[test]
fn input_buffer_multiple_paste_blocks() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_char(' ');
buf.insert_paste("C\nD\nE".to_string());
assert_eq!(buf.paste_block_total_lines(), 5); assert_eq!(buf.flat_text(), "A\nB C\nD\nE");
}
#[test]
fn input_buffer_insert_char_at_paste_block_boundary() {
let mut buf = InputBuffer::default();
buf.insert_paste("X\nY".to_string());
buf.insert_char('z');
assert_eq!(buf.flat_text(), "X\nYz");
}
#[test]
fn input_buffer_backspace_between_two_paste_blocks() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_paste("C\nD".to_string());
buf.backspace();
assert_eq!(buf.paste_block_total_lines(), 2); assert_eq!(buf.flat_text(), "A\nB");
}
#[test]
fn input_buffer_delete_forward_at_paste_block() {
let mut buf = InputBuffer::default();
buf.insert_char('a');
buf.insert_paste("X\nY".to_string());
buf.cursor = (0, 1);
buf.delete_forward();
assert!(!buf.has_paste_blocks());
assert_eq!(buf.flat_text(), "a");
}
#[test]
fn input_buffer_palette_not_active_with_paste_blocks() {
let mut buf = InputBuffer::default();
buf.insert_char('/');
buf.insert_char('h');
buf.insert_paste("block\ndata".to_string());
assert!(buf.has_paste_blocks());
assert!(buf.starts_with_slash());
}
#[test]
fn input_buffer_move_end_then_insert_char_on_trailing_paste() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.move_end();
buf.insert_char('z');
assert_eq!(buf.flat_text(), "A\nBz");
}
#[test]
fn input_buffer_move_home_then_insert_char_on_leading_paste() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_char('x'); buf.move_home();
buf.insert_char('z');
assert_eq!(buf.flat_text(), "zA\nBx");
}
#[test]
fn input_buffer_set_text_empty() {
let mut buf = InputBuffer::default();
buf.insert_char('x');
buf.insert_paste("A\nB".to_string());
buf.set_text(String::new());
assert!(buf.is_empty());
assert_eq!(buf.cursor, (0, 0));
}
#[test]
fn input_buffer_move_left_inserts_text_before_leading_paste() {
let mut buf = InputBuffer::default();
buf.insert_paste("X\nY".to_string());
buf.move_left();
assert!(matches!(buf.segments[0], InputSegment::Text(_)));
assert_eq!(buf.cursor.0, 0);
buf.insert_char('z');
assert_eq!(buf.flat_text(), "zX\nY");
}
#[test]
fn input_buffer_move_left_through_consecutive_paste_blocks() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_paste("C\nD".to_string());
buf.move_left();
assert!(matches!(buf.segments[buf.cursor.0], InputSegment::Text(_)));
buf.move_left();
assert!(matches!(buf.segments[buf.cursor.0], InputSegment::Text(_)));
buf.insert_char('z');
assert_eq!(buf.flat_text(), "zA\nBC\nD");
}
#[test]
fn input_buffer_move_right_through_consecutive_paste_blocks() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_paste("C\nD".to_string());
buf.insert_paste("E\nF".to_string());
buf.move_home();
buf.move_right();
assert!(matches!(buf.segments[buf.cursor.0], InputSegment::Text(_)));
}
#[test]
fn input_buffer_consecutive_paste_inserts() {
let mut buf = InputBuffer::default();
buf.insert_paste("A\nB".to_string());
buf.insert_paste("C\nD".to_string());
assert_eq!(buf.flat_text(), "A\nBC\nD");
assert_eq!(buf.paste_block_total_lines(), 4);
}
}