use std::sync::{Arc, Mutex, OnceLock};
use crate::status::StatusLine;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditMode {
Prompt,
Turn,
}
pub struct EditView {
line: String,
cursor: usize,
mode: EditMode,
status: Option<Arc<StatusLine>>,
menu_rows: usize,
menu_hidden: bool,
menu_enabled: bool,
prompt_width: usize,
queue_count: usize,
context: Arc<Mutex<EditContext>>,
drawn_rows: usize,
drawn_cursor_row: usize,
}
#[derive(Default)]
pub struct EditContext {
pub config: crate::config::Config,
pub recents: crate::recents::SharedRecents,
}
pub type SharedView = Arc<Mutex<EditView>>;
impl EditView {
pub fn shared(status: Option<Arc<StatusLine>>, context: Arc<Mutex<EditContext>>) -> SharedView {
Arc::new(Mutex::new(Self {
line: String::new(),
cursor: 0,
mode: EditMode::Prompt,
status,
menu_rows: 0,
menu_hidden: false,
menu_enabled: false,
prompt_width: 2,
queue_count: 0,
context,
drawn_rows: 0,
drawn_cursor_row: 0,
}))
}
pub fn set_mode(&mut self, mode: EditMode) {
self.mode = mode;
if let Some(status) = &self.status {
match mode {
EditMode::Turn => self.show_on_status(status),
_ => status.set_input(None, 0),
}
}
}
pub fn set_queue_count(&mut self, count: Option<usize>) {
self.queue_count = count.unwrap_or(0);
if let Some(status) = self.on_status() {
self.show_on_status(status);
}
}
pub fn context_handle(&self) -> Arc<Mutex<EditContext>> {
self.context.clone()
}
fn on_status(&self) -> Option<&StatusLine> {
(self.mode == EditMode::Turn).then_some(self.status.as_deref()).flatten()
}
fn byte_of(&self, idx: usize) -> usize {
self.line.char_indices().nth(idx).map(|(i, _)| i).unwrap_or(self.line.len())
}
fn insert(&mut self, text: &str) {
let at = self.byte_of(self.cursor);
self.line.insert_str(at, text);
self.cursor += text.chars().count();
match self.on_status() {
Some(status) => self.show_on_status(status),
None => self.redraw(),
}
self.line_changed();
}
fn show_on_status(&self, status: &StatusLine) {
if self.line.is_empty() && self.queue_count == 0 {
status.set_input(None, 0);
} else {
status.set_input(Some((&self.line, self.cursor)), self.queue_count);
}
}
fn line_changed(&mut self) {
self.menu_hidden = false;
self.draw_menu();
}
fn menu_visible(&self) -> bool {
self.menu_rows > 0
}
fn draw_menu(&mut self) {
if !self.menu_enabled || self.mode != EditMode::Prompt {
return;
}
let (rows, cols) = crate::status::terminal_size().unwrap_or((24, 80));
let reserved = if self.status.is_some() { 2 } else { 1 };
let max_rows = (rows as usize).saturating_sub(reserved).min(16);
let lines = if self.menu_hidden {
Vec::new()
} else if crate::commands::has_argument_menu(&self.line) {
let context = self.context.lock().unwrap();
let recents = context.recents.lock().unwrap().models().to_vec();
let found = crate::commands::suggestions(&context.config, &recents, &self.line);
crate::commands::suggestion_menu(&found, &self.line, cols as usize, max_rows)
} else {
crate::commands::menu(&self.line, cols as usize, max_rows)
};
let (seq, used) = menu_sequence(self.menu_rows, &lines, self.status.is_some());
self.menu_rows = used;
if !seq.is_empty() {
write(&seq);
if let Some(status) = &self.status {
status.draw();
}
}
}
fn hide_menu(&mut self) {
self.menu_hidden = true;
self.draw_menu();
}
fn tab(&mut self) {
if !self.line.starts_with('/') {
self.insert(" ");
return;
}
let completed = {
let context = self.context.lock().unwrap();
let recents = context.recents.lock().unwrap().models().to_vec();
crate::commands::complete_line(&context.config, &recents, &self.line)
};
match completed {
Some(done) => self.replace_line(&done),
None if self.line.contains(char::is_whitespace)
&& !crate::commands::has_argument_menu(&self.line) =>
{
self.insert(" ");
}
None => {}
}
}
fn replace_line(&mut self, line: &str) {
let shared = self.line.chars().zip(line.chars()).take_while(|(a, b)| a == b).count();
let extra = self.line.chars().count().saturating_sub(shared);
self.cursor = self.line.chars().count();
for _ in 0..extra {
self.backspace();
}
let tail: String = line.chars().skip(shared).collect();
if !tail.is_empty() {
self.insert(&tail);
}
}
pub fn prompt(&mut self) -> String {
let stamp = crate::ui::stamp();
self.prompt_width = crate::ui::visible_width(&stamp) + 2;
format!("{stamp}> {}", self.line)
}
fn content_rows(&self, cols: usize) -> usize {
if cols == 0 {
return 1;
}
let (mut row, mut col) = self.prompt_start(cols);
for c in self.line.chars() {
if c == '\n' {
row += 1;
col = 0;
} else {
if col == cols {
row += 1;
col = 0;
}
col += 1;
}
}
row + 1
}
fn prompt_start(&self, cols: usize) -> (usize, usize) {
let cols = cols.max(1);
let w = self.prompt_width;
if w > 0 && w.is_multiple_of(cols) {
(w / cols - 1, cols)
} else {
(w / cols, w % cols)
}
}
fn cursor_position(&self, cols: usize) -> (usize, usize) {
let cols = cols.max(1);
let (mut row, mut col) = self.prompt_start(cols);
for c in self.line.chars().take(self.cursor) {
if c == '\n' {
row += 1;
col = 0;
} else {
if col == cols {
row += 1;
col = 0;
}
col += 1;
}
}
(row, col)
}
fn redraw(&mut self) {
if !self.menu_enabled || self.mode != EditMode::Prompt {
return;
}
let (rows, cols) = crate::status::terminal_size().unwrap_or((24, 80));
let cols = (cols as usize).max(1);
let content = self.content_rows(cols);
let (cursor_row, cursor_col) = self.cursor_position(cols);
let clear_below = self.drawn_rows.saturating_sub(content).saturating_sub(cursor_row);
let up = self.drawn_cursor_row;
let reserve = if self.status.is_some() { content.min((rows as usize).saturating_sub(1)) } else { 0 };
self.drawn_rows = content + self.menu_rows;
self.drawn_cursor_row = cursor_row;
let bar = self.status.as_ref().and_then(|s| s.draw_seq());
let seq = redraw_sequence(&crate::ui::stamp(), &self.line, up, reserve, clear_below, (cursor_row, cursor_col), bar.as_deref());
write(&seq);
}
fn restamp_prompt(&self) {
let stamp = crate::ui::stamp();
if !self.menu_enabled || self.mode != EditMode::Prompt || stamp.is_empty() || self.prompt_width <= 2 {
return;
}
let cols = crate::status::terminal_size().map(|(_, c)| c as usize).unwrap_or(80);
let up = self.cursor_position(cols).0;
let up = if up > 0 { format!("\x1b[{up}A") } else { String::new() };
write(&format!("\x1b7{up}\r{stamp}\x1b8"));
}
pub fn prompt_redrawn(&mut self) {
self.menu_rows = 0;
let content = self.content_rows(crate::status::terminal_size().map(|(_, c)| c as usize).unwrap_or(80));
self.drawn_rows = content;
self.drawn_cursor_row = content.saturating_sub(1);
self.draw_menu();
}
pub fn resize(&mut self) {
let cols = crate::status::terminal_size().map(|(_, c)| c as usize).unwrap_or(80);
self.drawn_cursor_row = self.cursor_position(cols).0;
self.redraw();
self.draw_menu();
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor -= 1;
let at = self.byte_of(self.cursor);
self.line.remove(at);
match self.on_status() {
Some(status) => self.show_on_status(status),
None => self.redraw(),
}
self.line_changed();
}
fn delete(&mut self) {
if self.cursor >= self.line.chars().count() {
return;
}
let at = self.byte_of(self.cursor);
self.line.remove(at);
match self.on_status() {
Some(status) => self.show_on_status(status),
None => self.redraw(),
}
self.line_changed();
}
fn erase_word(&mut self) {
let before: String = self.line.chars().take(self.cursor).collect();
let trimmed = before.trim_end();
let word_start = trimmed
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
let start = before[..word_start].chars().count();
let from = self.byte_of(start);
let to = self.byte_of(self.cursor);
self.line.replace_range(from..to, "");
self.cursor = start;
match self.on_status() {
Some(status) => self.show_on_status(status),
None => self.redraw(),
}
self.line_changed();
}
fn clear_line(&mut self) {
if self.line.is_empty() {
return;
}
self.line.clear();
self.cursor = 0;
match self.on_status() {
Some(status) => self.show_on_status(status),
None => self.redraw(),
}
self.line_changed();
}
fn move_to(&mut self, idx: usize) {
let idx = idx.min(self.line.chars().count());
if idx == self.cursor {
return;
}
self.cursor = idx;
match self.on_status() {
Some(status) => self.show_on_status(status),
None => self.redraw(),
}
}
fn move_left(&mut self) {
self.move_to(self.cursor.saturating_sub(1));
}
fn move_right(&mut self) {
self.move_to(self.cursor + 1);
}
fn move_home(&mut self) {
self.move_to(0);
}
fn move_end(&mut self) {
self.move_to(self.line.chars().count());
}
fn move_word_left(&mut self) {
let before: Vec<char> = self.line.chars().take(self.cursor).collect();
let mut i = before.len();
while i > 0 && before[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !before[i - 1].is_whitespace() {
i -= 1;
}
self.move_to(i);
}
fn move_word_right(&mut self) {
let chars: Vec<char> = self.line.chars().collect();
let mut i = self.cursor;
while i < chars.len() && !chars[i].is_whitespace() {
i += 1;
}
while i < chars.len() && chars[i].is_whitespace() {
i += 1;
}
self.move_to(i);
}
fn take(&mut self) -> String {
if self.menu_visible() {
let (seq, _) = menu_sequence(self.menu_rows, &[], self.status.is_some());
write(&seq);
self.menu_rows = 0;
}
self.menu_hidden = false;
self.restamp_prompt();
let line = std::mem::take(&mut self.line);
self.cursor = 0;
self.drawn_rows = 0;
self.drawn_cursor_row = 0;
match self.on_status() {
Some(status) => self.show_on_status(status),
None => {
write("\x1b[J\r\n");
}
}
line
}
}
fn redraw_sequence(
stamp: &str,
line: &str,
up: usize,
reserve: usize,
clear_below: usize,
cursor: (usize, usize),
bar: Option<&str>,
) -> String {
let (cursor_row, cursor_col) = cursor;
let mut seq = String::new();
if up > 0 {
seq.push_str(&format!("\x1b[{up}A"));
}
if reserve > 0 {
seq.push_str(&"\n".repeat(reserve));
seq.push_str(&format!("\x1b[{reserve}A"));
}
seq.push_str(&format!("\x1b7\r{stamp}> {line}\x1b[J"));
if clear_below > 0 {
seq.push_str(&format!("\x1b[{clear_below}B\x1b[J"));
}
seq.push_str("\x1b8");
if cursor_row > 0 {
seq.push_str(&format!("\x1b[{cursor_row}B"));
}
if cursor_col > 0 {
seq.push_str(&format!("\r\x1b[{cursor_col}C"));
} else {
seq.push('\r');
}
if let Some(bar) = bar {
seq.push_str(bar);
}
seq
}
fn menu_sequence(old_rows: usize, lines: &[String], _anchor: bool) -> (String, usize) {
if old_rows == 0 && lines.is_empty() {
return (String::new(), 0);
}
let mut seq = String::new();
if lines.len() > old_rows {
seq.push_str(&"\n".repeat(lines.len()));
seq.push_str(&format!("\x1b[{}A", lines.len()));
}
let rows = old_rows.max(lines.len());
seq.push_str("\x1b7");
for i in 0..rows {
seq.push_str("\x1b[1B\r\x1b[2K");
if let Some(line) = lines.get(i) {
seq.push_str(line);
}
}
seq.push_str("\x1b8");
(seq, if lines.is_empty() { 0 } else { rows })
}
fn write(text: &str) {
use std::io::Write;
crate::status::with_term_lock(|| {
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes());
let _ = out.flush();
});
}
pub enum Key {
Line(String),
Eof,
Interrupt,
ToggleThinking,
Escape,
CycleMode,
}
const ESCAPE_SEQUENCE_WAIT_MS: i32 = 30;
static ORIGINAL: OnceLock<libc::termios> = OnceLock::new();
static KEY_MODE_ENTERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn get_termios() -> Option<libc::termios> {
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
(unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } == 0).then_some(termios)
}
pub fn restore_terminal() {
if let Some(original) = ORIGINAL.get() {
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) };
}
if KEY_MODE_ENTERED.load(std::sync::atomic::Ordering::SeqCst) {
write("\x1b[?2004l\x1b[<u\x1b[>4;0m");
}
}
struct KeyMode;
impl KeyMode {
fn enter() -> Option<Self> {
let original = get_termios()?;
let _ = ORIGINAL.set(original);
let mut raw = *ORIGINAL.get().unwrap();
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
raw.c_cc[libc::VMIN] = 1;
raw.c_cc[libc::VTIME] = 0;
let entered = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } == 0;
if entered {
KEY_MODE_ENTERED.store(true, std::sync::atomic::Ordering::SeqCst);
write("\x1b[?1000l\x1b[?1001l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004h\x1b[>4;1m\x1b[>1u");
}
entered.then_some(Self)
}
}
impl Drop for KeyMode {
fn drop(&mut self) {
restore_terminal();
}
}
#[derive(Debug)]
enum Esc {
Left,
Right,
Home,
End,
WordLeft,
WordRight,
Delete,
Newline,
Submit,
Ignored,
}
#[derive(Default)]
pub struct LineReader {
pending: std::collections::VecDeque<u8>,
utf8: Vec<u8>,
suspend: Arc<std::sync::atomic::AtomicBool>,
}
const SUSPEND_POLL_MS: i32 = 15;
impl LineReader {
pub fn with_suspend(suspend: Arc<std::sync::atomic::AtomicBool>) -> Self {
Self { suspend, ..Self::default() }
}
fn first_byte(&mut self) -> Option<u8> {
loop {
if self.suspend.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(SUSPEND_POLL_MS as u64));
continue;
}
if let Some(byte) = self.pending.pop_front() {
return Some(byte);
}
let mut fd = libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 };
let ready = unsafe { libc::poll(&mut fd, 1, SUSPEND_POLL_MS) };
if ready < 0 {
if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
continue;
}
return None;
}
if ready == 0 {
continue; }
if self.suspend.load(std::sync::atomic::Ordering::SeqCst) {
continue;
}
return self.next_byte();
}
}
}
impl LineReader {
fn next_byte(&mut self) -> Option<u8> {
if let Some(byte) = self.pending.pop_front() {
return Some(byte);
}
let mut buf = [0u8; 1024];
loop {
let n = unsafe { libc::read(libc::STDIN_FILENO, buf.as_mut_ptr().cast(), buf.len()) };
if n > 0 {
self.pending.extend(&buf[..n as usize]);
return self.pending.pop_front();
}
if n == 0 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
return None;
}
}
}
fn byte_within(&mut self, ms: i32) -> Option<u8> {
if self.pending.is_empty() {
if self.suspend.load(std::sync::atomic::Ordering::SeqCst) {
return None;
}
let mut fd = libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 };
if unsafe { libc::poll(&mut fd, 1, ms) } <= 0 {
return None;
}
}
self.next_byte()
}
pub fn read_line(&mut self, view: &SharedView, send: &dyn Fn(Key)) -> Key {
let _mode = KeyMode::enter();
view.lock().unwrap().menu_enabled = true;
let shared = view;
loop {
let Some(byte) = self.first_byte() else { return Key::Eof };
let mut view = view.lock().unwrap();
match byte {
b'\r' | b'\n' => {
if byte == b'\r' && self.pending.front() == Some(&b'\n') {
self.pending.pop_front();
}
return Key::Line(view.take() + "\n");
}
0x01 => view.move_home(),
0x03 => {
view.clear_line();
drop(view);
send(Key::Interrupt);
}
0x04 if view.line.is_empty() => return Key::Eof,
0x05 => view.move_end(),
0x0f => {
drop(view);
send(Key::ToggleThinking);
}
0x7f | 0x08 => view.backspace(),
0x15 => view.clear_line(),
0x17 => view.erase_word(),
0x1b => {
let menu = view.menu_visible();
drop(view);
match self.byte_within(ESCAPE_SEQUENCE_WAIT_MS) {
Some(intro @ (b'[' | b'O')) => {
let mut seq = vec![0x1b, intro];
while let Some(b) = self.next_byte() {
seq.push(b);
if (0x40..=0x7e).contains(&b) {
break;
}
}
if seq == [0x1b, b'[', b'M'] {
let mut coords = Vec::with_capacity(3);
for _ in 0..3 {
match self.byte_within(0) {
Some(b) => coords.push(b),
None => break,
}
}
if coords.len() < 3 {
for b in coords.into_iter().rev() {
self.pending.push_front(b);
}
}
}
if seq == [0x1b, b'[', b'Z'] {
send(Key::CycleMode);
} else if seq == b"\x1b[200~" {
let text = read_paste(self);
if !text.is_empty() {
shared.lock().unwrap().insert(&text);
}
} else {
match parse_escape(&seq) {
Esc::Left => shared.lock().unwrap().move_left(),
Esc::Right => shared.lock().unwrap().move_right(),
Esc::Home => shared.lock().unwrap().move_home(),
Esc::End => shared.lock().unwrap().move_end(),
Esc::WordLeft => shared.lock().unwrap().move_word_left(),
Esc::WordRight => shared.lock().unwrap().move_word_right(),
Esc::Delete => shared.lock().unwrap().delete(),
Esc::Newline => shared.lock().unwrap().insert("\n"),
Esc::Submit => return Key::Line(shared.lock().unwrap().take() + "\n"),
Esc::Ignored => {}
}
}
}
None if menu => shared.lock().unwrap().hide_menu(),
None => send(Key::Escape),
Some(0x1b) => {
self.pending.push_front(0x1b);
send(Key::Escape);
}
Some(b'b') => shared.lock().unwrap().move_word_left(),
Some(b'f') => shared.lock().unwrap().move_word_right(),
Some(_) => {}
}
}
b'\t' => view.tab(),
byte if byte < 0x20 => {}
byte => {
self.utf8.push(byte);
match std::str::from_utf8(&self.utf8) {
Ok(text) => {
let text = text.to_string();
self.utf8.clear();
view.insert(&text);
}
Err(e) if e.error_len().is_some() => self.utf8.clear(),
Err(_) => {}
}
}
}
}
}
}
fn parse_escape(seq: &[u8]) -> Esc {
match seq {
[0x1b, b'O', b'D'] => return Esc::Left,
[0x1b, b'O', b'C'] => return Esc::Right,
[0x1b, b'O', b'H'] => return Esc::Home,
[0x1b, b'O', b'F'] => return Esc::End,
[0x1b, b'b'] => return Esc::WordLeft,
[0x1b, b'f'] => return Esc::WordRight,
_ => {}
}
let [0x1b, b'[', body @ .., final_byte] = seq else {
return Esc::Ignored;
};
let body = std::str::from_utf8(body).unwrap_or("");
if body.starts_with('<') {
return Esc::Ignored;
}
match (*final_byte, body) {
(b'D', "") => Esc::Left,
(b'C', "") => Esc::Right,
(b'H', "") => Esc::Home,
(b'F', "") => Esc::End,
(b'Z', "") => Esc::Ignored, (b'~', "3") => Esc::Delete,
(b'~', "1" | "7") => Esc::Home,
(b'~', "4" | "8") => Esc::End,
(dir @ (b'C' | b'D' | b'H' | b'F'), params) => {
let encoded: u16 = params.rsplit(';').next().and_then(|m| m.parse().ok()).unwrap_or(1);
let bits = encoded.saturating_sub(1);
let word = bits & 0b110 != 0;
match (dir, word) {
(b'C', true) => Esc::WordRight,
(b'D', true) => Esc::WordLeft,
(b'C', false) => Esc::Right,
(b'D', false) => Esc::Left,
(b'H', _) => Esc::Home,
(b'F', _) => Esc::End,
_ => Esc::Ignored,
}
}
(b'~', params) if params.starts_with("27;") => {
let mut parts = params.split(';');
let (_, modifier, key) = (parts.next(), parts.next(), parts.next());
match (modifier.and_then(|m| m.parse::<u16>().ok()), key) {
(Some(5 | 9), Some("13")) => Esc::Newline,
_ => Esc::Ignored,
}
}
(b'u', params) => {
let mut parts = params.split(';');
match (parts.next().and_then(|k| k.parse::<u16>().ok()), parts.next().and_then(|m| m.parse::<u16>().ok())) {
(Some(13), Some(m)) if m & 0b100 != 0 || m & 0b1000 != 0 => Esc::Newline,
(Some(13), None | Some(1)) => Esc::Submit,
_ => Esc::Ignored,
}
}
_ => Esc::Ignored,
}
}
fn read_paste(reader: &mut LineReader) -> String {
const END: &[u8] = b"\x1b[201~";
let mut bytes = Vec::new();
while let Some(byte) = reader.next_byte() {
bytes.push(byte);
if bytes.ends_with(END) {
bytes.truncate(bytes.len() - END.len());
break;
}
}
let text = String::from_utf8_lossy(&bytes);
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.push('\n');
}
'\n' => out.push('\n'),
c if c.is_control() => {}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn view(line: &str) -> EditView {
let mut view = EditView {
line: line.into(),
cursor: line.chars().count(),
mode: EditMode::Turn,
status: None,
menu_rows: 0,
menu_hidden: false,
menu_enabled: false,
prompt_width: 2,
queue_count: 0,
context: Arc::new(Mutex::new(EditContext::default())),
drawn_rows: 0,
drawn_cursor_row: 0,
};
view.mode = EditMode::Turn;
view
}
#[test]
fn erase_word_handles_multibyte_spaces() {
let mut view = view("run a\u{a0}bé ");
view.erase_word();
assert_eq!(view.line, "run a\u{a0}");
view.erase_word();
assert_eq!(view.line, "run ");
}
#[test]
fn menu_rows_are_reserved_drawn_and_cleared() {
let lines = vec!["a".to_string(), "b".to_string()];
let (seq, rows) = menu_sequence(0, &lines, false);
assert_eq!(rows, 2);
assert_eq!(seq, "\n\n\x1b[2A\x1b7\x1b[1B\r\x1b[2Ka\x1b[1B\r\x1b[2Kb\x1b8");
let (seq, rows) = menu_sequence(2, &lines[..1], false);
assert_eq!((seq.as_str(), rows), ("\x1b7\x1b[1B\r\x1b[2Ka\x1b[1B\r\x1b[2K\x1b8", 2));
let (seq, rows) = menu_sequence(2, &[], false);
assert_eq!((seq.as_str(), rows), ("\x1b7\x1b[1B\r\x1b[2K\x1b[1B\r\x1b[2K\x1b8", 0));
assert_eq!(menu_sequence(0, &[], false), (String::new(), 0));
}
#[test]
fn redraw_reserves_rows_for_a_tall_prompt_and_repaints_the_bar() {
let bar = "\x1b7\x1b[24;1H\x1b[2KBAR\x1b8";
let seq = redraw_sequence("", "abc def ghi", 3, 4, 0, (3, 5), Some(bar));
assert!(seq.contains(&"\n".repeat(4)), "opens 4 rows: {seq:?}");
assert!(seq.contains("\x1b[4A"), "climbs back the reserved rows: {seq:?}");
assert!(seq.contains("\x1b[3A"), "climbs to the prompt row first: {seq:?}");
assert!(seq.ends_with(bar), "repaints the bar last: {seq:?}");
assert!(!seq.contains('r'), "no scroll region command: {seq:?}");
let plain = redraw_sequence("", "abc def", 0, 0, 0, (0, 7), None);
assert!(!plain.contains('\n'), "no rows reserved without a status bar: {plain:?}");
assert!(plain.ends_with("\x1b[7C"), "ends at the cursor column: {plain:?}");
}
#[test]
fn redraw_repaints_the_bar_last_even_when_reserve_is_clamped() {
let bar = "\x1b7\x1b[24;1H\x1b[2KBAR\x1b8";
let tall = "x".repeat(500);
let seq = redraw_sequence("", &tall, 5, 3, 0, (5, 4), Some(bar));
assert!(seq.contains(&"\n".repeat(3)), "opens the clamped rows: {seq:?}");
assert!(seq.ends_with(bar), "repaints the bar last so it is never covered: {seq:?}");
assert!(!seq.contains('r'), "no scroll region command: {seq:?}");
}
#[test]
fn menu_sequence_sets_no_scroll_region() {
let lines = vec!["a".to_string(), "b".to_string()];
for (seq, _) in [
menu_sequence(0, &lines, true),
menu_sequence(2, &lines[..1], true),
menu_sequence(2, &[], true),
menu_sequence(0, &lines, false),
] {
assert!(!seq.contains("\x1b[1;"), "no region may be set: {seq:?}");
assert!(!seq.contains('r'), "no region may be set: {seq:?}");
}
}
#[test]
fn tab_completes_commands_and_is_a_space_elsewhere() {
let mut v = view("/comp");
v.tab();
assert_eq!(v.line, "/compact ");
let mut v = view("/s");
v.tab();
assert_eq!(v.line, "/s", "ambiguous: unchanged");
let mut v = view("fix it");
v.tab();
assert_eq!(v.line, "fix it ");
}
#[test]
fn inserts_and_deletes_at_the_cursor() {
let mut view = view("helo");
view.move_left();
view.move_left();
view.insert("l");
assert_eq!(view.line, "hello");
assert_eq!(view.cursor, 3);
view.backspace();
assert_eq!(view.line, "helo");
assert_eq!(view.cursor, 2);
view.delete();
assert_eq!(view.line, "heo");
view.move_home();
view.insert(">> ");
assert_eq!(view.line, ">> heo");
view.move_end();
view.insert("!");
assert_eq!(view.line, ">> heo!");
}
#[test]
fn movement_clamps_at_the_ends() {
let mut view = view("ab");
view.move_left();
view.move_left();
view.move_left();
assert_eq!(view.cursor, 0);
view.move_right();
view.move_right();
view.move_right();
assert_eq!(view.cursor, 2);
}
#[test]
fn word_jumps_skip_whitespace() {
let mut view = view("foo bar baz");
view.move_word_left();
assert_eq!(view.cursor, 9);
view.move_word_left();
assert_eq!(view.cursor, 4);
view.move_word_left();
assert_eq!(view.cursor, 0);
view.move_word_right();
assert_eq!(view.cursor, 4);
view.move_word_right();
assert_eq!(view.cursor, 9);
}
#[test]
fn erase_word_from_the_middle_keeps_the_tail() {
let mut view = view("foo bar baz");
view.move_to(8); view.erase_word();
assert_eq!(view.line, "foo baz");
assert_eq!(view.cursor, 4);
}
#[test]
fn newlines_make_multiple_rows() {
let mut view = view("");
view.prompt_width = 2;
view.insert("one\ntwo\nthree");
assert_eq!(view.content_rows(80), 3);
view.move_to(4); assert_eq!(view.cursor_position(80), (1, 0));
view.move_to(0);
assert_eq!(view.cursor_position(80), (0, 2));
}
#[test]
fn wraps_long_lines_onto_more_rows() {
let mut view = view("");
view.prompt_width = 2;
view.insert("abcdef");
assert_eq!(view.content_rows(4), 2); assert_eq!(view.cursor_position(4), (1, 4), "the last char fills the row: pending wrap");
view.move_to(2);
assert_eq!(view.cursor_position(4), (0, 4), "pending wrap stays on the row");
}
#[test]
fn parses_cursor_and_function_keys() {
assert!(matches!(parse_escape(b"\x1b[D"), Esc::Left));
assert!(matches!(parse_escape(b"\x1b[C"), Esc::Right));
assert!(matches!(parse_escape(b"\x1b[H"), Esc::Home));
assert!(matches!(parse_escape(b"\x1b[F"), Esc::End));
assert!(matches!(parse_escape(b"\x1bOD"), Esc::Left));
assert!(matches!(parse_escape(b"\x1bOC"), Esc::Right));
assert!(matches!(parse_escape(b"\x1bOH"), Esc::Home));
assert!(matches!(parse_escape(b"\x1bOF"), Esc::End));
assert!(matches!(parse_escape(b"\x1b[3~"), Esc::Delete));
assert!(matches!(parse_escape(b"\x1b[1~"), Esc::Home));
assert!(matches!(parse_escape(b"\x1b[4~"), Esc::End));
assert!(matches!(parse_escape(b"\x1b[A"), Esc::Ignored));
assert!(matches!(parse_escape(b"\x1b[B"), Esc::Ignored));
}
#[test]
fn parses_modified_cursor_keys() {
assert!(matches!(parse_escape(b"\x1b[1;5D"), Esc::WordLeft));
assert!(matches!(parse_escape(b"\x1b[1;5C"), Esc::WordRight));
assert!(matches!(parse_escape(b"\x1b[1;3D"), Esc::WordLeft));
assert!(matches!(parse_escape(b"\x1b[1;3C"), Esc::WordRight));
assert!(matches!(parse_escape(b"\x1bb"), Esc::WordLeft));
assert!(matches!(parse_escape(b"\x1bf"), Esc::WordRight));
assert!(matches!(parse_escape(b"\x1b[1;2D"), Esc::Left));
assert!(matches!(parse_escape(b"\x1b[1;6D"), Esc::WordLeft));
}
#[test]
fn parses_ctrl_and_cmd_enter_as_a_newline() {
assert!(matches!(parse_escape(b"\x1b[27;5;13~"), Esc::Newline), "Ctrl-Enter");
assert!(matches!(parse_escape(b"\x1b[27;9;13~"), Esc::Newline), "Cmd-Enter");
assert!(matches!(parse_escape(b"\x1b[27;2;13~"), Esc::Ignored), "Shift-Enter is left alone");
assert!(matches!(parse_escape(b"\x1b[13;5u"), Esc::Newline), "kitty Ctrl-Enter");
assert!(matches!(parse_escape(b"\x1b[13;9u"), Esc::Newline), "kitty Cmd-Enter");
assert!(matches!(parse_escape(b"\x1b[13u"), Esc::Submit), "kitty Enter (no modifier) submits");
assert!(matches!(parse_escape(b"\x1b[13;1u"), Esc::Submit), "kitty Enter (modifier 1) submits");
}
#[test]
fn ignores_sgr_mouse_reports() {
assert!(matches!(parse_escape(b"\x1b[<0;10;5M"), Esc::Ignored));
assert!(matches!(parse_escape(b"\x1b[<0;10;5m"), Esc::Ignored));
assert!(matches!(parse_escape(b"\x1b[<64;10;5M"), Esc::Ignored));
assert!(matches!(parse_escape(b"\x1b[<65;10;5M"), Esc::Ignored));
}
#[test]
fn paste_normalizes_line_endings() {
let mut reader = LineReader::default();
reader.pending.extend(b"one\r\ntwo\rthree\nfour\x1b[201~".iter());
assert_eq!(read_paste(&mut reader), "one\ntwo\nthree\nfour");
}
#[test]
fn take_returns_the_multiline_input() {
let mut view = view("one\ntwo");
assert_eq!(view.take(), "one\ntwo");
assert!(view.line.is_empty());
assert_eq!(view.cursor, 0);
}
#[test]
fn tab_completes_model_arguments_from_context() {
let context = Arc::new(Mutex::new(EditContext {
config: toml::from_str(r#"model = "openai/gpt-4o""#).unwrap(),
recents: crate::recents::SharedRecents::default(),
}));
context.lock().unwrap().recents.lock().unwrap().record("ollama/qwen3:8b");
let view = EditView::shared(None, context);
let mut view = view.lock().unwrap();
view.mode = EditMode::Turn;
view.line = "/model ol".into();
view.tab();
assert_eq!(view.line, "/model ollama", "common prefix first");
view.tab();
assert_eq!(view.line, "/model ollama/qwen3:8b", "then descend to the recent spec");
view.line = "/mode a".into();
view.tab();
assert_eq!(view.line, "/mode auto");
view.line = "/model zz".into();
view.tab();
assert_eq!(view.line, "/model zz", "no match: unchanged");
view.line = "/model ol".into();
view.tab();
assert_eq!(view.line, "/model ollama", "irregular spacing canonicalizes without panicking");
}
}