use std::cell::Cell;
use std::io::{self, Stdout, Write as _};
use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use tui_textarea::{CursorMove, TextArea};
use crate::{footer_continues, InputSurface, ReadOutcome};
const GUTTER_W: u16 = 19;
const MAX_INPUT_ROWS: u16 = 8;
const GUTTER_MAX_FRACTION: f32 = 0.33;
type Term = Terminal<CrosstermBackend<Stdout>>;
fn use_gutter(width: u16) -> bool {
width > 0 && (GUTTER_W as f32) <= GUTTER_MAX_FRACTION * width as f32
}
fn resolve_gutter(setting: Option<u16>, width: u16) -> u16 {
match setting {
None => {
if use_gutter(width) {
GUTTER_W
} else {
0
}
}
Some(n) => n.min(width.saturating_sub(1)),
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Step {
Continue,
Submit,
SubmitQuit,
Eof,
}
#[derive(Clone, Copy, PartialEq)]
enum Confirm {
SubmitQuit,
}
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Normal,
Insert,
}
#[derive(Clone, Copy, PartialEq)]
enum Pending {
None,
Op(char),
Replace,
G,
Find(char),
}
struct Vi {
mode: Mode,
pending: Pending,
count: usize,
ex: Option<String>,
confirm: Option<Confirm>,
insert_normal: bool,
jback: Vec<(usize, usize)>,
jfwd: Vec<(usize, usize)>,
msg: Option<String>,
last_find: Option<(char, char)>,
}
impl Vi {
fn new() -> Self {
Self {
mode: Mode::Insert,
pending: Pending::None,
count: 0,
ex: None,
confirm: None,
insert_normal: false,
jback: Vec::new(),
jfwd: Vec::new(),
msg: None,
last_find: None,
}
}
fn take_count(&mut self) -> usize {
let n = self.count.max(1);
self.count = 0;
n
}
fn record_jump(&mut self, ta: &TextArea) {
self.jback.push(ta.cursor());
self.jfwd.clear();
}
fn jump_back(&mut self, ta: &mut TextArea) {
if let Some(prev) = self.jback.pop() {
self.jfwd.push(ta.cursor());
ta.move_cursor(CursorMove::Jump(prev.0 as u16, prev.1 as u16));
}
}
fn jump_forward(&mut self, ta: &mut TextArea) {
if let Some(next) = self.jfwd.pop() {
self.jback.push(ta.cursor());
ta.move_cursor(CursorMove::Jump(next.0 as u16, next.1 as u16));
}
}
fn format_jumps(&self) -> String {
let fmt = |v: &[(usize, usize)]| {
if v.is_empty() {
"—".to_string()
} else {
v.iter()
.map(|(r, c)| format!("{}:{}", r + 1, c + 1))
.collect::<Vec<_>>()
.join(" ")
}
};
format!(
"jumps back: {} forward: {}",
fmt(&self.jback),
fmt(&self.jfwd)
)
}
fn ex_input(&mut self, key: KeyEvent) -> Step {
match key.code {
KeyCode::Esc => self.ex = None,
KeyCode::Enter => {
let cmd = self.ex.take().unwrap_or_default();
match cmd.as_str() {
"w" => return Step::Submit,
"wq" | "x" => {
self.confirm = Some(Confirm::SubmitQuit);
return Step::Continue;
}
"wq!" | "x!" => return Step::SubmitQuit,
"q" | "q!" => return Step::Eof,
"jumps" => self.msg = Some(self.format_jumps()),
"help" | "h" => self.msg = Some(help_text(Edit::Vi)),
_ => {} }
return Step::Continue;
}
KeyCode::Backspace => {
if let Some(ex) = self.ex.as_mut() {
if ex.pop().is_none() {
self.ex = None; }
}
}
KeyCode::Char(c) => {
if let Some(ex) = self.ex.as_mut() {
ex.push(c);
}
}
_ => {}
}
Step::Continue
}
fn confirm_input(&mut self, key: KeyEvent, what: Confirm) -> Step {
self.confirm = None;
let yes = matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'));
match what {
Confirm::SubmitQuit if yes => Step::SubmitQuit,
_ => Step::Continue,
}
}
fn input(&mut self, key: KeyEvent, ta: &mut TextArea) -> Step {
if let Some(what) = self.confirm {
return self.confirm_input(key, what);
}
if self.ex.is_some() {
return self.ex_input(key);
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match self.mode {
Mode::Insert => {
if ctrl && key.code == KeyCode::Char('o') {
self.mode = Mode::Normal;
self.insert_normal = true;
} else if key.code == KeyCode::Esc {
self.mode = Mode::Normal;
ta.move_cursor(CursorMove::Back);
} else {
ta.input(key);
}
Step::Continue
}
Mode::Normal => {
if key.code == KeyCode::Esc {
self.pending = Pending::None;
self.count = 0;
self.insert_normal = false;
return Step::Continue;
}
if ctrl && key.code == KeyCode::Char('r') {
ta.redo();
return Step::Continue;
}
if ctrl && key.code == KeyCode::Char('o') {
self.jump_back(ta);
return Step::Continue;
}
if key.code == KeyCode::Tab || key.code == KeyCode::BackTab {
self.jump_forward(ta);
return Step::Continue;
}
let step = self.normal(key, ta);
if self.insert_normal && self.pending == Pending::None && self.count == 0 {
self.mode = Mode::Insert;
self.insert_normal = false;
}
step
}
}
}
fn normal(&mut self, key: KeyEvent, ta: &mut TextArea) -> Step {
let KeyCode::Char(c) = key.code else {
match key.code {
KeyCode::Left | KeyCode::Backspace => ta.move_cursor(CursorMove::Back),
KeyCode::Right => ta.move_cursor(CursorMove::Forward),
KeyCode::Up => ta.move_cursor(CursorMove::Up),
KeyCode::Down => ta.move_cursor(CursorMove::Down),
_ => {}
}
return Step::Continue;
};
match self.pending {
Pending::Replace => {
ta.delete_next_char();
ta.insert_char(c);
ta.move_cursor(CursorMove::Back);
self.pending = Pending::None;
return Step::Continue;
}
Pending::G => {
self.pending = Pending::None;
if c == 'g' {
self.record_jump(ta); ta.move_cursor(CursorMove::Top);
}
return Step::Continue;
}
Pending::Op(op) => {
self.apply_operator(op, c, ta);
self.pending = Pending::None;
return Step::Continue;
}
Pending::Find(kind) => {
self.pending = Pending::None;
self.last_find = Some((kind, c));
char_search(ta, kind, c);
return Step::Continue;
}
Pending::None => {}
}
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
return Step::Continue;
}
if is_motion(c) {
let n = self.take_count();
if c == 'G' {
self.record_jump(ta); }
apply_motion(ta, c, n);
return Step::Continue;
}
let n = self.take_count();
match c {
'i' => self.mode = Mode::Insert,
'I' => {
ta.move_cursor(CursorMove::Head);
self.mode = Mode::Insert;
}
'a' => {
ta.move_cursor(CursorMove::Forward);
self.mode = Mode::Insert;
}
'A' => {
ta.move_cursor(CursorMove::End);
self.mode = Mode::Insert;
}
'o' => {
ta.move_cursor(CursorMove::End);
ta.insert_newline();
self.mode = Mode::Insert;
}
'O' => {
ta.move_cursor(CursorMove::Head);
ta.insert_newline();
ta.move_cursor(CursorMove::Up);
self.mode = Mode::Insert;
}
'x' => {
for _ in 0..n {
ta.delete_next_char();
}
}
'X' => {
for _ in 0..n {
ta.delete_char();
}
}
'D' => {
ta.delete_line_by_end();
}
'C' => {
ta.delete_line_by_end();
self.mode = Mode::Insert;
}
'J' => {
let joins = n.saturating_sub(1).max(1);
for _ in 0..joins {
if ta.cursor().0 + 1 >= ta.lines().len() {
break;
}
ta.move_cursor(CursorMove::End);
ta.insert_char(' ');
ta.delete_next_char(); }
}
's' => {
for _ in 0..n {
ta.delete_next_char();
}
self.mode = Mode::Insert;
}
'S' => {
ta.move_cursor(CursorMove::Head);
ta.delete_line_by_end();
self.mode = Mode::Insert;
}
'r' => self.pending = Pending::Replace,
'u' => {
ta.undo();
}
'p' | 'P' => {
ta.paste();
}
'd' | 'c' | 'y' => self.pending = Pending::Op(c),
'g' => self.pending = Pending::G,
'f' | 'F' | 't' | 'T' => self.pending = Pending::Find(c),
';' => {
if let Some((kind, target)) = self.last_find {
char_search(ta, kind, target);
}
}
',' => {
if let Some((kind, target)) = self.last_find {
char_search(ta, reverse_find(kind), target);
}
}
':' => self.ex = Some(String::new()),
_ => {
self.msg = Some("vi NORMAL — press i to insert · :help for commands".to_string());
}
}
Step::Continue
}
fn apply_operator(&mut self, op: char, target: char, ta: &mut TextArea) {
if target == op {
match op {
'c' => {
ta.move_cursor(CursorMove::Head);
ta.delete_line_by_end();
self.mode = Mode::Insert;
}
'd' => {
ta.move_cursor(CursorMove::Head);
ta.delete_line_by_end();
ta.delete_next_char();
}
'y' => {
let start = ta.cursor();
ta.move_cursor(CursorMove::Head);
ta.start_selection();
ta.move_cursor(CursorMove::End);
ta.copy();
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start.0 as u16, start.1 as u16));
}
_ => {}
}
return;
}
if !is_motion(target) {
return;
}
let start = ta.cursor();
ta.start_selection();
apply_motion(ta, target, 1);
match op {
'd' => {
ta.cut();
}
'c' => {
ta.cut();
self.mode = Mode::Insert;
}
'y' => {
ta.copy();
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start.0 as u16, start.1 as u16));
}
_ => ta.cancel_selection(),
}
}
#[cfg(test)]
fn mode_label(&self) -> &'static str {
match self.mode {
Mode::Normal => "vi N",
Mode::Insert => "vi I",
}
}
}
fn is_motion(c: char) -> bool {
matches!(
c,
'h' | 'l' | 'j' | 'k' | 'w' | 'W' | 'b' | 'B' | 'e' | 'E' | '0' | '^' | '$' | 'G'
)
}
fn apply_motion(ta: &mut TextArea, c: char, n: usize) {
let mv = match c {
'h' => CursorMove::Back,
'l' => CursorMove::Forward,
'j' => CursorMove::Down,
'k' => CursorMove::Up,
'w' | 'W' => CursorMove::WordForward,
'b' | 'B' => CursorMove::WordBack,
'e' | 'E' => CursorMove::WordEnd,
'0' | '^' => CursorMove::Head,
'$' => CursorMove::End,
'G' => CursorMove::Bottom,
_ => return,
};
for _ in 0..n {
ta.move_cursor(mv);
}
}
fn char_search(ta: &mut TextArea, kind: char, target: char) {
let (row, col) = ta.cursor();
let chars: Vec<char> = ta.lines()[row].chars().collect();
let dest = match kind {
'f' => (col + 1..chars.len()).find(|&i| chars[i] == target),
't' => (col + 1..chars.len())
.find(|&i| chars[i] == target)
.map(|i| i - 1),
'F' => (0..col).rev().find(|&i| chars[i] == target),
'T' => (0..col).rev().find(|&i| chars[i] == target).map(|i| i + 1),
_ => None,
};
if let Some(i) = dest {
ta.move_cursor(CursorMove::Jump(row as u16, i as u16));
}
}
fn reverse_find(kind: char) -> char {
match kind {
'f' => 'F',
'F' => 'f',
't' => 'T',
'T' => 't',
other => other,
}
}
fn help_text(edit: Edit) -> String {
match edit {
Edit::Vi => [
"vi Esc=NORMAL · i I a A o O=insert · :w=send · :wq=send+end · :q=quit · :jumps :help",
" move: h j k l · w b e · 0 ^ $ · gg G · f F t T ; , · Ctrl-O/Ctrl-I jumps",
" edit: x X · dd dw D C s S r · yy p · J join · u Ctrl-R · d/c/y+motion",
]
.join("\n"),
Edit::Nano => {
"nano Enter=submit · Ctrl-O or Shift-Enter=newline · ^X=exit · ^G=help".to_string()
}
Edit::Emacs => {
"emacs Enter=submit · Ctrl-O or Shift-Enter=newline · C-x C-c=exit · Ctrl-h=help"
.to_string()
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Edit {
Emacs,
Nano,
Vi,
}
impl Edit {
fn is_modeless(self) -> bool {
matches!(self, Self::Emacs | Self::Nano)
}
}
fn current_edit() -> Edit {
match crate::resolve_edit_mode() {
newt_core::EditMode::Vi => Edit::Vi,
newt_core::EditMode::Nano => Edit::Nano,
newt_core::EditMode::Emacs => Edit::Emacs,
}
}
struct Editor {
edit: Edit,
vi: Vi,
cx_pending: bool,
}
impl Editor {
fn new(edit: Edit) -> Self {
Self {
edit,
vi: Vi::new(),
cx_pending: false,
}
}
fn input(&mut self, key: KeyEvent, ta: &mut TextArea) -> Step {
if self.edit == Edit::Vi && self.vi.confirm.is_some() {
return self.vi.input(key, ta);
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if self.cx_pending {
self.cx_pending = false;
if ctrl && key.code == KeyCode::Char('c') {
return Step::Eof;
}
}
if ctrl && key.code == KeyCode::Char('x') {
match self.edit {
Edit::Emacs => {
self.cx_pending = true;
return Step::Continue;
}
Edit::Nano => return Step::Eof,
Edit::Vi => {}
}
}
if ctrl {
match key.code {
KeyCode::Char('c') => {
*ta = new_textarea(self.edit);
self.vi = Vi::new();
self.cx_pending = false;
self.vi.msg = Some("Ctrl-C to interrupt · Ctrl-D to exit".to_string());
return Step::Continue;
}
KeyCode::Char('d') => {
return if buffer_is_empty(ta) {
Step::Eof
} else {
Step::Submit
};
}
KeyCode::Char('o') if self.edit.is_modeless() => {
ta.insert_newline();
return Step::Continue;
}
KeyCode::Char('g') if self.edit == Edit::Nano => {
self.vi.msg = Some(help_text(self.edit));
return Step::Continue;
}
KeyCode::Char('h') if self.edit == Edit::Emacs => {
self.vi.msg = Some(help_text(self.edit));
return Step::Continue;
}
_ => {}
}
}
if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT) {
ta.insert_newline();
return Step::Continue;
}
if key.code == KeyCode::Enter && self.ex().is_none() {
let body = ta.lines().join("\n");
if footer_continues(&body) {
ta.insert_newline();
return Step::Continue;
}
return Step::Submit;
}
if self.edit.is_modeless() {
ta.input(key);
return Step::Continue;
}
self.vi.input(key, ta)
}
#[cfg(test)]
fn label(&self) -> &'static str {
match self.edit {
Edit::Emacs => "emacs",
Edit::Nano => "nano",
Edit::Vi => self.vi.mode_label(),
}
}
fn ex(&self) -> Option<&str> {
if self.edit == Edit::Vi {
self.vi.ex.as_deref()
} else {
None
}
}
fn is_vi_normal(&self) -> bool {
self.edit == Edit::Vi && self.vi.mode == Mode::Normal
}
fn mode_hint(&self) -> &'static str {
match self.edit {
Edit::Vi => match self.vi.mode {
Mode::Insert => {
"vi INSERT — Esc: NORMAL · :help · /nano /emacs · ^C interrupt · ^D exit"
}
Mode::Normal => {
"vi NORMAL — i: insert · :cmd · /nano /emacs · ^C interrupt · ^D exit"
}
},
Edit::Emacs => "emacs — Enter sends · Ctrl-h help · /vi /nano · ^C interrupt · ^D exit",
Edit::Nano => "nano — Enter sends · ^G help · /vi /emacs · ^C interrupt · ^D exit",
}
}
fn header_mode(&self) -> &'static str {
match self.edit {
Edit::Emacs => "emacs",
Edit::Nano => "nano",
Edit::Vi => match self.vi.mode {
Mode::Insert => "vi --INSERT--",
Mode::Normal => "vi --NORMAL--",
},
}
}
fn confirm_prompt(&self) -> Option<&'static str> {
if self.edit != Edit::Vi {
return None;
}
match self.vi.confirm {
Some(Confirm::SubmitQuit) => Some("send prompt then quit? [y/N] "),
None => None,
}
}
fn take_msg(&mut self) -> Option<String> {
self.vi.msg.take()
}
}
fn buffer_is_empty(ta: &TextArea) -> bool {
ta.lines().iter().all(|l| l.is_empty())
}
fn make_terminal(height: u16) -> io::Result<Term> {
Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
}
fn new_textarea(edit: Edit) -> TextArea<'static> {
let mut ta = TextArea::default();
let hint = match edit {
Edit::Vi => "type… (Esc=NORMAL · o/O open line · Enter submit · :help · :q quit)",
Edit::Nano => "type… (Enter submit · Ctrl-O/Shift-Enter newline · ^G help · ^X exit)",
Edit::Emacs => {
"type… (Enter submit · Ctrl-O/Shift-Enter newline · Ctrl-h help · C-x C-c exit)"
}
};
ta.set_placeholder_text(hint);
ta.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
ta.set_cursor_line_style(Style::default());
ta
}
fn textarea_with(edit: Edit, content: &str) -> TextArea<'static> {
let mut ta = new_textarea(edit);
ta.insert_str(content);
ta
}
fn history_step(pos: usize, len: usize, up: bool) -> Option<usize> {
if up {
(pos > 0).then(|| pos - 1)
} else {
(pos < len).then(|| pos + 1)
}
}
fn load_history(path: Option<&PathBuf>) -> Vec<String> {
let Some(p) = path else {
return Vec::new();
};
std::fs::read_to_string(p)
.map(|s| {
s.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn status_options() -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for var in ["NEWT_LOADOUT", "NEWT_DGX_MODEL"] {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
parts.push(v);
}
}
}
(!parts.is_empty()).then(|| parts.join(" "))
}
fn header_line(
editor: &Editor,
model: &str,
endpoint: &str,
gauge: Option<(u32, u32)>,
active: bool,
) -> Line<'static> {
let accent = Color::Rgb(255, 165, 90);
let dim = Color::DarkGray;
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut spans: Vec<Span> = vec![Span::styled(format!("[{stamp}]"), Style::default().fg(dim))];
spans.push(Span::styled(
format!(" {}", editor.header_mode()),
Style::default().fg(if active { accent } else { dim }),
));
if !model.is_empty() {
let loc = if endpoint.is_empty() {
model.to_string()
} else {
format!("{model} @ {endpoint}")
};
spans.push(Span::styled(format!(" {loc}"), Style::default().fg(dim)));
}
if let Some(opts) = status_options() {
spans.push(Span::styled(format!(" [{opts}]"), Style::default().fg(dim)));
}
if let Some((used, budget)) = gauge {
if budget > 0 {
let g = newt_core::agentic::fmt_token_gauge(used, budget);
let c = match newt_core::agentic::gauge_level(used, budget) {
newt_core::agentic::GaugeLevel::Ok => Color::Green,
newt_core::agentic::GaugeLevel::Warn => Color::Rgb(200, 140, 0),
newt_core::agentic::GaugeLevel::Critical => Color::Red,
};
spans.push(Span::styled(format!(" {g}"), Style::default().fg(c)));
}
}
Line::from(spans)
}
fn prompt_line(editor: &Editor, ex_inline: bool) -> Line<'static> {
if let Some(question) = editor.confirm_prompt() {
return Line::from(Span::styled(
question.to_string(),
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
));
}
let accent = Color::Rgb(255, 165, 90);
let bold_hi = Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD);
if ex_inline {
if let Some(ex) = editor.ex() {
return Line::from(Span::styled(format!(":{ex}"), bold_hi));
}
}
if editor.is_vi_normal() {
Line::from(Span::styled(": ", bold_hi))
} else {
Line::from(Span::styled("❯ ", Style::default().fg(accent)))
}
}
fn ex_bottom_line(editor: &Editor, textarea: &TextArea) -> Option<String> {
editor
.ex()
.filter(|_| textarea.lines().len() > 1)
.map(str::to_string)
}
fn draw(
f: &mut Frame,
textarea: &TextArea,
editor: &Editor,
gutter: Option<u16>,
model: &str,
endpoint: &str,
gauge: Option<(u32, u32)>,
) {
let area = f.area();
let empty = buffer_is_empty(textarea);
let [header_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
f.render_widget(
Paragraph::new(header_line(editor, model, endpoint, gauge, !empty)),
header_area,
);
let g = resolve_gutter(gutter, input_area.width);
if let Some(ex) = ex_bottom_line(editor, textarea) {
let [msg_area, ex_area] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(input_area);
let prompt = prompt_line(editor, false);
if g >= GUTTER_W {
let [gutter_area, input] =
Layout::horizontal([Constraint::Length(g), Constraint::Min(1)]).areas(msg_area);
f.render_widget(Paragraph::new(prompt), gutter_area);
f.render_widget(textarea, input);
} else {
draw_overhang(f, msg_area, &prompt, textarea, g, None);
}
let bold_hi = Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD);
f.render_widget(
Paragraph::new(Line::from(Span::styled(format!(":{ex}"), bold_hi))),
ex_area,
);
let cx = ex_area.x + 1 + ex.chars().count() as u16;
if cx <= ex_area.right().saturating_sub(1) {
f.set_cursor_position((cx, ex_area.y));
}
return;
}
let prompt = prompt_line(editor, true);
let hint = empty.then(|| editor.mode_hint());
if g >= GUTTER_W {
let [gutter_area, input] =
Layout::horizontal([Constraint::Length(g), Constraint::Min(1)]).areas(input_area);
f.render_widget(Paragraph::new(prompt), gutter_area);
f.render_widget(textarea, input);
} else {
draw_overhang(f, input_area, &prompt, textarea, g, hint);
}
}
fn wrap_segments(text: &str, first_w: usize, cont_w: usize) -> Vec<(usize, String)> {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return vec![(0, String::new())];
}
let mut segs: Vec<(usize, String)> = Vec::new();
let mut start = 0;
while start < chars.len() {
let avail = if segs.is_empty() { first_w } else { cont_w }.max(1);
if chars.len() - start <= avail {
segs.push((start, chars[start..].iter().collect()));
break;
}
let hard_end = start + avail;
let break_at = (start..hard_end)
.rev()
.find(|&i| chars[i] == ' ')
.map(|i| i + 1)
.unwrap_or(hard_end)
.max(start + 1);
segs.push((start, chars[start..break_at].iter().collect()));
start = break_at;
}
segs
}
fn overhang_rows(
prompt: &Line<'static>,
lines: &[String],
cursor: (usize, usize),
g: u16,
width: u16,
hint: Option<&str>,
) -> (Vec<Line<'static>>, u16, u16) {
let pw = prompt.width() as u16;
let (crow, ccol) = cursor;
let cont_w = width.saturating_sub(g).max(1) as usize;
let mut rows: Vec<Line> = Vec::new();
let (mut cx, mut cy) = (pw, 0u16);
for (i, text) in lines.iter().enumerate() {
let first_indent = if i == 0 { pw } else { g };
let first_w = width.saturating_sub(first_indent).max(1) as usize;
let segs = wrap_segments(text, first_w, cont_w);
let n = segs.len();
for (s, (seg_start, seg_text)) in segs.into_iter().enumerate() {
let indent = if s == 0 { first_indent } else { g };
let line = if i == 0 && s == 0 {
let mut spans = prompt.spans.clone();
spans.push(Span::raw(seg_text.clone()));
if let Some(h) = hint {
spans.push(Span::styled(
h.to_string(),
Style::default().fg(Color::DarkGray),
));
}
Line::from(spans)
} else {
Line::from(vec![
Span::raw(" ".repeat(indent as usize)),
Span::raw(seg_text.clone()),
])
};
if i == crow {
let seg_end = seg_start + seg_text.chars().count();
let last = s + 1 == n;
if (ccol >= seg_start && ccol < seg_end) || (last && ccol >= seg_end) {
cy = rows.len() as u16;
cx = indent + (ccol.saturating_sub(seg_start)) as u16;
}
}
rows.push(line);
}
}
(rows, cx, cy)
}
fn draw_overhang(
f: &mut Frame,
area: Rect,
prompt: &Line<'static>,
textarea: &TextArea,
g: u16,
hint: Option<&str>,
) {
let (rows, cx, cy) = overhang_rows(
prompt,
textarea.lines(),
textarea.cursor(),
g,
area.width,
hint,
);
let last_row = area.height.saturating_sub(1);
let scroll_y = cy.saturating_sub(last_row);
f.render_widget(Paragraph::new(rows).scroll((scroll_y, 0)), area);
let cur_x = area.x + cx;
let cur_y = area.y + cy - scroll_y;
if cur_x <= area.right().saturating_sub(1) && cur_y <= area.bottom().saturating_sub(1) {
f.set_cursor_position((cur_x, cur_y));
}
}
fn echo_submitted(terminal: &mut Term, body: &str, gutter: Option<u16>) -> io::Result<()> {
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let width = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
let hang = " ".repeat(resolve_gutter(gutter, width) as usize);
let n = body.lines().count().max(1) as u16;
let dim = Style::default().fg(Color::DarkGray);
terminal.insert_before(n + 1, |buf| {
let mut lines: Vec<Line> = vec![Line::from(Span::styled(format!("[{stamp}]"), dim))];
for (i, l) in body.lines().enumerate() {
let prefix = if i == 0 {
Span::styled("› ", dim)
} else {
Span::raw(hang.clone())
};
lines.push(Line::from(vec![prefix, Span::raw(l.to_string())]));
}
Paragraph::new(lines).render(buf.area, buf);
})
}
fn echo_note(terminal: &mut Term, note: &str) -> io::Result<()> {
let rows: Vec<&str> = note.lines().collect();
let n = rows.len().max(1) as u16;
terminal.insert_before(n, |buf| {
let lines: Vec<Line> = rows
.iter()
.map(|r| {
Line::from(Span::styled(
r.to_string(),
Style::default().fg(Color::Gray),
))
})
.collect();
Paragraph::new(lines).render(buf.area, buf);
})
}
pub(crate) struct RichSurface {
edit: Edit,
history_path: Option<PathBuf>,
unsaved: Vec<String>,
gutter: Option<u16>,
pending_end_quit: Cell<bool>,
model: String,
endpoint: String,
gauge: Option<(u32, u32)>,
}
impl RichSurface {
pub(crate) fn new(history_path: Option<PathBuf>) -> anyhow::Result<Self> {
Ok(Self {
edit: current_edit(),
history_path,
unsaved: Vec::new(),
gutter: crate::resolve_gutter_setting(),
pending_end_quit: Cell::new(false),
model: String::new(),
endpoint: String::new(),
gauge: None,
})
}
fn read_turn(&self) -> io::Result<ReadOutcome> {
enable_raw_mode()?;
let _ = crossterm::execute!(io::stdout(), EnableBracketedPaste);
let outcome = self.event_loop();
let _ = crossterm::execute!(io::stdout(), DisableBracketedPaste);
let _ = disable_raw_mode();
outcome
}
fn event_loop(&self) -> io::Result<ReadOutcome> {
let mut cur_h = 1u16;
let mut terminal = make_terminal(cur_h)?;
terminal.clear()?;
let mut textarea = new_textarea(self.edit);
let mut editor = Editor::new(self.edit);
let mut history = load_history(self.history_path.as_ref());
history.extend(self.unsaved.iter().cloned());
let mut hist_pos = history.len();
let mut stash = String::new();
loop {
let term_w = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
let ex_extra = u16::from(ex_bottom_line(&editor, &textarea).is_some());
let rows = if resolve_gutter(self.gutter, term_w) >= GUTTER_W {
textarea.lines().len() as u16
} else {
let empty = buffer_is_empty(&textarea);
let prompt = prompt_line(&editor, ex_extra == 0);
overhang_rows(
&prompt,
textarea.lines(),
textarea.cursor(),
resolve_gutter(self.gutter, term_w),
term_w,
empty.then(|| editor.mode_hint()),
)
.0
.len() as u16
};
let want = (rows + ex_extra).clamp(1, MAX_INPUT_ROWS) + 1;
if want != cur_h {
terminal.clear()?;
terminal = make_terminal(want)?;
terminal.clear()?;
cur_h = want;
}
terminal.draw(|f| {
draw(
f,
&textarea,
&editor,
self.gutter,
&self.model,
&self.endpoint,
self.gauge,
);
})?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let evt = event::read()?;
if let Event::Paste(text) = evt {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
textarea.insert_str(normalized);
continue;
}
let Event::Key(key) = evt else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if matches!(key.code, KeyCode::Up | KeyCode::Down)
&& key.modifiers.is_empty()
&& editor.ex().is_none()
&& editor.confirm_prompt().is_none()
&& !history.is_empty()
{
let (row, _) = textarea.cursor();
let last_row = textarea.lines().len().saturating_sub(1);
let at_edge = (key.code == KeyCode::Up && row == 0)
|| (key.code == KeyCode::Down && row == last_row);
if at_edge {
let up = key.code == KeyCode::Up;
if let Some(next) = history_step(hist_pos, history.len(), up) {
if hist_pos == history.len() {
stash = textarea.lines().join("\n");
}
hist_pos = next;
let content = if hist_pos == history.len() {
stash.clone()
} else {
history[hist_pos].clone()
};
textarea = textarea_with(self.edit, &content);
}
continue;
}
}
let step = editor.input(key, &mut textarea);
if let Some(note) = editor.take_msg() {
echo_note(&mut terminal, ¬e)?;
}
match step {
Step::Continue => {}
Step::Submit => {
let body = textarea.lines().join("\n");
if body.trim().is_empty() {
continue;
}
echo_submitted(&mut terminal, &body, self.gutter)?;
return Ok(ReadOutcome::Line(body));
}
Step::SubmitQuit => {
let body = textarea.lines().join("\n");
if body.trim().is_empty() {
return Ok(ReadOutcome::EndAndQuit);
}
echo_submitted(&mut terminal, &body, self.gutter)?;
self.pending_end_quit.set(true);
return Ok(ReadOutcome::Line(body));
}
Step::Eof => return Ok(ReadOutcome::Eof),
}
}
}
}
impl InputSurface for RichSurface {
fn read_line(&mut self, _prompt: &str) -> anyhow::Result<ReadOutcome> {
if self.pending_end_quit.replace(false) {
return Ok(ReadOutcome::EndAndQuit);
}
Ok(self.read_turn()?)
}
fn add_history(&mut self, entry: &str) {
self.unsaved.push(entry.to_string());
}
fn save_history(&mut self) {
let Some(hp) = self.history_path.as_ref() else {
return;
};
if self.unsaved.is_empty() {
return;
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(hp)
{
for e in &self.unsaved {
let _ = writeln!(f, "{}", e.replace('\n', " "));
}
}
self.unsaved.clear();
}
fn reload(&mut self) -> anyhow::Result<()> {
self.edit = current_edit();
self.gutter = crate::resolve_gutter_setting();
Ok(())
}
fn set_runtime_context(&mut self, model: &str, endpoint: &str, gauge: Option<(u32, u32)>) {
self.model = model.to_string();
self.endpoint = endpoint.to_string();
self.gauge = gauge;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_segments_empty_and_fitting() {
assert_eq!(wrap_segments("", 10, 10), vec![(0, String::new())]);
assert_eq!(wrap_segments("hi", 10, 10), vec![(0, "hi".to_string())]);
}
#[test]
fn wrap_segments_breaks_at_spaces_word_wrap() {
let segs = wrap_segments("hello world foo", 8, 8);
assert_eq!(
segs,
vec![
(0, "hello ".to_string()),
(6, "world ".to_string()),
(12, "foo".to_string()),
]
);
let joined: String = segs.iter().map(|(_, s)| s.as_str()).collect();
assert_eq!(joined, "hello world foo");
}
#[test]
fn wrap_segments_hard_breaks_an_unbreakable_run() {
assert_eq!(
wrap_segments("abcdefghij", 4, 4),
vec![
(0, "abcd".to_string()),
(4, "efgh".to_string()),
(8, "ij".to_string()),
]
);
}
#[test]
fn wrap_segments_honors_a_narrower_first_width() {
let segs = wrap_segments("abcdefghi", 3, 6);
assert_eq!(segs[0], (0, "abc".to_string()));
assert_eq!(segs[1], (3, "defghi".to_string()));
}
#[test]
fn overhang_rows_wraps_a_long_line_and_tracks_the_cursor() {
let prompt = Line::from("❯ "); let lines = vec!["hello world foo".to_string()];
let (rows, cx, cy) = overhang_rows(&prompt, &lines, (0, 15), 1, 8, None);
assert!(rows.len() >= 2, "the long line wrapped to multiple rows");
assert_eq!(cy as usize, rows.len() - 1);
assert!(cx >= 1, "cursor is indented on the continuation row");
}
#[test]
fn overhang_rows_short_line_is_one_row_after_the_prompt() {
let prompt = Line::from("❯ "); let (rows, cx, cy) = overhang_rows(&prompt, &["hi".to_string()], (0, 2), 1, 80, None);
assert_eq!(rows.len(), 1);
assert_eq!(cy, 0);
assert_eq!(cx, 2 + 2, "prompt width (2) + cursor col (2)");
}
#[test]
fn overhang_rows_cursor_sits_on_the_hint_not_after_it() {
let prompt = Line::from("❯ "); let (rows, cx, cy) = overhang_rows(
&prompt,
&[String::new()],
(0, 0),
1,
80,
Some("vi INSERT — type…"),
);
assert_eq!((cx, cy), (2, 0), "cursor at prompt end, on the hint");
let text: String = rows[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("vi INSERT"),
"hint rendered after the cursor: {text:?}"
);
}
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn special(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn vi_editor() -> Editor {
Editor::new(Edit::Vi)
}
fn emacs_editor() -> Editor {
Editor::new(Edit::Emacs)
}
fn type_chars(ed: &mut Editor, ta: &mut TextArea, s: &str) {
for c in s.chars() {
ed.input(key(c), ta);
}
}
fn nano_editor() -> Editor {
Editor::new(Edit::Nano)
}
fn run_ex(ed: &mut Editor, ta: &mut TextArea, cmd: &str) -> Step {
ed.input(special(KeyCode::Esc), ta); ed.input(key(':'), ta);
for c in cmd.chars() {
ed.input(key(c), ta);
}
ed.input(special(KeyCode::Enter), ta)
}
#[test]
fn vi_unbound_normal_key_emits_a_hint() {
let mut ed = vi_editor();
let mut ta = new_textarea(Edit::Vi);
type_chars(&mut ed, &mut ta, "hi");
ed.input(special(KeyCode::Esc), &mut ta); let _ = ed.take_msg(); ed.input(key('q'), &mut ta); let msg = ed
.take_msg()
.expect("an unbound NORMAL key should surface a hint");
assert!(msg.contains("insert"), "hint nudges toward insert: {msg:?}");
assert_eq!(ta.lines(), &["hi"], "`q` still types nothing in NORMAL");
}
#[test]
fn vi_ex_command_renders_on_a_bottom_row_when_multiline() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut ed = vi_editor();
let mut ta = TextArea::new(vec!["hello".to_string(), "world".to_string()]);
ed.input(special(KeyCode::Esc), &mut ta); ed.input(key(':'), &mut ta); type_chars(&mut ed, &mut ta, "wq");
assert!(
ex_bottom_line(&ed, &ta).is_some(),
"multi-line ex → bottom row"
);
let mut term = Terminal::new(TestBackend::new(40, 4)).unwrap();
term.draw(|f| draw(f, &ta, &ed, Some(1), "", "", None))
.unwrap();
let buf = term.backend().buffer();
let row = |y: u16| -> String {
(0..40)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
};
assert!(
row(3).starts_with(":wq"),
"command on the bottom row: {:?}",
row(3)
);
assert!(
row(1).contains("hello"),
"message renders below the header: {:?}",
row(1)
);
assert!(
!row(0).contains(":wq") && !row(1).contains(":wq"),
"command must NOT be glued to the input rows"
);
}
#[test]
fn vi_ex_command_stays_inline_on_a_single_line() {
let mut ed = vi_editor();
let mut ta = TextArea::new(vec!["hi".to_string()]);
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key(':'), &mut ta);
type_chars(&mut ed, &mut ta, "wq");
assert!(
ex_bottom_line(&ed, &ta).is_none(),
"single-line stays inline"
);
let line = prompt_line(&ed, true);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains(":wq"), "single-line ex is inline: {text:?}");
}
#[test]
fn vi_ex_command_bottom_row_in_wide_gutter_mode() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut ed = vi_editor();
let mut ta = TextArea::new(vec!["hello".to_string(), "world".to_string()]);
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key(':'), &mut ta);
type_chars(&mut ed, &mut ta, "wq");
let mut term = Terminal::new(TestBackend::new(80, 4)).unwrap();
term.draw(|f| draw(f, &ta, &ed, Some(25), "", "", None))
.unwrap(); let buf = term.backend().buffer();
let last: String = (0..80)
.map(|x| buf.cell((x, 3)).unwrap().symbol().to_string())
.collect();
assert!(
last.starts_with(":wq"),
"command on the bottom row (wide gutter): {last:?}"
);
}
#[test]
fn overhang_prompt_is_inline_with_one_space_hanging_continuation() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let editor = vi_editor();
let ta = TextArea::new(vec!["this".to_string(), "more".to_string()]);
let mut term = Terminal::new(TestBackend::new(80, 3)).unwrap();
term.draw(|f| draw(f, &ta, &editor, Some(1), "m", "http://e:1", None))
.unwrap();
let buf = term.backend().buffer();
let row = |y: u16| -> String {
(0..80)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
};
assert!(
row(0).contains("vi --INSERT--") && row(0).contains("m @ http://e:1"),
"header row carries mode + model @ endpoint: {:?}",
row(0)
);
assert!(
row(1).contains("❯ this"),
"first input line rides on the prompt row: {:?}",
row(1)
);
assert!(
row(2).starts_with(" more"),
"continuation is 1-space hang-indented: {:?}",
row(2)
);
}
fn row_text(editor: &Editor) -> String {
prompt_line(editor, true)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
fn header_text(editor: &Editor, model: &str, endpoint: &str) -> String {
header_line(editor, model, endpoint, None, true)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn header_shows_context_budget_gauge_when_known() {
let ed = vi_editor();
let text = |g| -> String {
header_line(&ed, "m", "e", g, true)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
assert!(
text(Some((972_000, 1_024_000))).contains("972k/1024k"),
"the gauge shows used/budget once the budget is known"
);
assert!(
!text(None).contains("k/"),
"no gauge until a budget is known"
);
assert!(
!text(Some((100, 0))).contains("k/"),
"a zero budget shows no gauge (no divide-by-zero, no noise)"
);
}
#[test]
fn native_status_row_shows_the_insert_indicator() {
let editor = vi_editor();
assert!(row_text(&editor).contains('❯'), "insert indicator");
}
#[test]
fn header_shows_datetime_mode_and_model_endpoint() {
let insert = vi_editor(); let h = header_text(&insert, "nemotron-3-nano:30b", "http://REDACTED-HOST:11434");
assert!(h.starts_with('['), "datetime stamp: {h:?}");
assert!(h.contains("vi --INSERT--"), "{h:?}");
assert!(
h.contains("nemotron-3-nano:30b @ http://REDACTED-HOST:11434"),
"{h:?}"
);
let mut normal = vi_editor();
let mut ta = TextArea::default();
normal.input(special(KeyCode::Esc), &mut ta);
assert!(header_text(&normal, "m", "e").contains("vi --NORMAL--"));
assert!(header_text(&emacs_editor(), "m", "e").contains("emacs"));
assert!(!header_text(&insert, "", "").contains('@'));
}
#[test]
fn mode_hint_advertises_the_other_editor_modes() {
assert!(vi_editor().mode_hint().contains("INSERT"));
assert!(vi_editor().mode_hint().contains("/nano"));
assert!(vi_editor().mode_hint().contains("/emacs"));
}
#[test]
fn native_status_row_uses_colon_for_vi_normal_not_arrow() {
let mut normal = vi_editor();
let mut ta = TextArea::default();
normal.input(special(KeyCode::Esc), &mut ta); let row = row_text(&normal);
assert!(!row.contains('❯'), "NORMAL drops the ❯ for `:`: {row:?}");
let mut ex = vi_editor();
ex.input(special(KeyCode::Esc), &mut ta);
ex.input(key(':'), &mut ta);
ex.input(key('w'), &mut ta);
let exrow = row_text(&ex);
assert!(exrow.contains(":w"), "ex line shows the command: {exrow:?}");
assert!(!exrow.contains('❯'));
}
#[test]
fn history_step_walks_older_then_back_to_the_fresh_line() {
let len = 3;
assert_eq!(history_step(3, len, true), Some(2));
assert_eq!(history_step(2, len, true), Some(1));
assert_eq!(history_step(1, len, true), Some(0));
assert_eq!(history_step(0, len, true), None, "oldest: nowhere up");
assert_eq!(history_step(0, len, false), Some(1));
assert_eq!(history_step(2, len, false), Some(3));
assert_eq!(history_step(3, len, false), None, "fresh: nowhere down");
assert_eq!(history_step(0, 0, true), None);
assert_eq!(history_step(0, 0, false), None);
}
#[serial_test::serial(real_fs)]
#[test]
fn load_history_reads_nonblank_lines_oldest_first() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history");
std::fs::write(&path, "first\n\nsecond\n \nthird\n").unwrap();
assert_eq!(
load_history(Some(&path)),
vec![
"first".to_string(),
"second".to_string(),
"third".to_string()
]
);
assert!(load_history(Some(&dir.path().join("nope"))).is_empty());
assert!(load_history(None).is_empty());
}
#[test]
fn textarea_with_prefills_content_for_recall() {
let ta = textarea_with(Edit::Vi, "recalled prompt");
assert_eq!(ta.lines(), &["recalled prompt".to_string()]);
}
#[test]
fn vi_w_submits_like_enter() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "hello");
assert_eq!(run_ex(&mut ed, &mut ta, "w"), Step::Submit);
assert!(ed.confirm_prompt().is_none());
}
#[test]
fn vi_wq_confirms_then_y_submit_quits() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "ship it");
assert_eq!(
run_ex(&mut ed, &mut ta, "wq"),
Step::Continue,
":wq must not submit until confirmed"
);
assert!(
ed.confirm_prompt().is_some(),
"the [y/N] question is showing"
);
assert_eq!(ed.input(key('y'), &mut ta), Step::SubmitQuit);
assert!(
ed.confirm_prompt().is_none(),
"confirm cleared after answer"
);
}
#[test]
fn vi_wq_confirm_cancels_on_n_or_enter() {
for answer in [KeyCode::Char('n'), KeyCode::Enter, KeyCode::Esc] {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "keep editing");
assert_eq!(run_ex(&mut ed, &mut ta, "wq"), Step::Continue);
assert_eq!(
ed.input(special(answer), &mut ta),
Step::Continue,
"{answer:?} cancels the confirm"
);
assert!(ed.confirm_prompt().is_none(), "confirm cleared on cancel");
assert_eq!(ta.lines(), &["keep editing".to_string()]);
}
}
#[test]
fn vi_wq_bang_forces_without_confirm() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "no prompt");
assert_eq!(run_ex(&mut ed, &mut ta, "wq!"), Step::SubmitQuit);
assert!(ed.confirm_prompt().is_none());
}
#[test]
fn vi_q_quits_without_sending() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "discard me");
assert_eq!(run_ex(&mut ed, &mut ta, "q"), Step::Eof);
}
#[test]
fn nano_is_modeless_and_labeled() {
let mut ed = nano_editor();
let mut ta = TextArea::default();
assert_eq!(ed.label(), "nano");
type_chars(&mut ed, &mut ta, "plain text");
assert_eq!(ed.label(), "nano", "no mode flip");
assert_eq!(ta.lines(), &["plain text".to_string()]);
assert_eq!(ed.input(ctrl('o'), &mut ta), Step::Continue);
assert_eq!(ed.input(special(KeyCode::Enter), &mut ta), Step::Submit);
assert!(Edit::Nano.is_modeless() && Edit::Emacs.is_modeless());
assert!(!Edit::Vi.is_modeless());
}
#[test]
fn resolve_gutter_auto_off_and_fixed() {
assert_eq!(
resolve_gutter(None, 80),
GUTTER_W,
"auto wide → inline gutter"
);
assert_eq!(resolve_gutter(None, 50), 0, "auto squished → stacked (0)");
assert_eq!(resolve_gutter(Some(0), 80), 0);
assert_eq!(resolve_gutter(Some(3), 80), 3, "3-space indent");
assert_eq!(
resolve_gutter(Some(25), 80),
25,
"wide enough to hold the prompt"
);
assert_eq!(resolve_gutter(Some(200), 80), 79, "clamped to width-1");
}
#[test]
fn use_gutter_drops_when_over_a_third() {
assert!(use_gutter(80), "gutter fits at 80 cols");
assert!(use_gutter(58), "19 <= 0.33*58 (19.14) → gutter stays on");
assert!(!use_gutter(57), "19 > 0.33*57 (18.81) → drop the gutter");
assert!(!use_gutter(40), "way too narrow → drop the gutter");
assert!(!use_gutter(0), "zero width never uses a gutter");
}
#[test]
fn emacs_enter_submits_and_ctrl_o_inserts_newline() {
let mut ed = emacs_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "hello");
assert_eq!(ed.input(ctrl('o'), &mut ta), Step::Continue);
type_chars(&mut ed, &mut ta, "world");
assert_eq!(ta.lines().len(), 2, "two lines after Ctrl-O");
assert_eq!(ed.input(special(KeyCode::Enter), &mut ta), Step::Submit);
}
#[test]
fn shift_enter_inserts_newline_without_submitting() {
let nl = || KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
let mut ed = emacs_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "line one");
assert_eq!(
ed.input(nl(), &mut ta),
Step::Continue,
"Shift-Enter newline"
);
type_chars(&mut ed, &mut ta, "line two");
assert_eq!(ta.lines().len(), 2, "Shift-Enter added a line");
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "vi line");
assert_eq!(ed.input(nl(), &mut ta), Step::Continue);
assert_eq!(ta.lines().len(), 2, "Shift-Enter newline in vi too");
let mut ed = emacs_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "x");
assert_eq!(ed.input(special(KeyCode::Enter), &mut ta), Step::Submit);
}
#[test]
fn ctrl_o_is_newline_in_modeless_but_reserved_in_vi() {
for ed_factory in [emacs_editor as fn() -> Editor, nano_editor] {
let mut ed = ed_factory();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "a");
assert_eq!(ed.input(ctrl('o'), &mut ta), Step::Continue);
assert_eq!(ta.lines().len(), 2, "Ctrl-O newline in modeless mode");
}
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "vi");
assert_eq!(ed.input(ctrl('o'), &mut ta), Step::Continue);
assert_eq!(ta.lines().len(), 1, "Ctrl-O does NOT newline in vi");
}
#[test]
fn enter_continues_an_open_bang_line() {
let mut ed = emacs_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "! ls \\");
assert_eq!(ed.input(special(KeyCode::Enter), &mut ta), Step::Continue);
assert_eq!(ta.lines().len(), 2, "Enter continued the bang line");
}
#[test]
fn ctrl_c_abandons_line_and_ctrl_d_empty_is_eof() {
let mut ed = emacs_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "throwaway");
assert_eq!(
ed.input(ctrl('c'), &mut ta),
Step::Continue,
"Ctrl-C does not exit"
);
assert!(buffer_is_empty(&ta), "Ctrl-C cleared the buffer");
assert_eq!(
ed.input(ctrl('d'), &mut ta),
Step::Eof,
"Ctrl-D empty → EOF"
);
type_chars(&mut ed, &mut ta, "x");
assert_eq!(ed.input(ctrl('d'), &mut ta), Step::Submit);
}
#[test]
fn vi_o_opens_line_below_and_enters_insert() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "first");
ed.input(special(KeyCode::Esc), &mut ta);
assert_eq!(ed.label(), "vi N");
ed.input(key('o'), &mut ta);
assert_eq!(ed.label(), "vi I");
type_chars(&mut ed, &mut ta, "second");
assert_eq!(ta.lines(), &["first".to_string(), "second".to_string()]);
}
#[test]
fn vi_uppercase_o_opens_line_above() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "below");
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key('O'), &mut ta);
type_chars(&mut ed, &mut ta, "above");
assert_eq!(ta.lines(), &["above".to_string(), "below".to_string()]);
}
#[test]
fn vi_dd_deletes_the_line() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "doomed");
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key('d'), &mut ta);
ed.input(key('d'), &mut ta);
assert_eq!(ta.lines(), &[String::new()], "dd cleared the only line");
}
#[test]
fn vi_x_with_count_deletes_n_chars() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "abcdef");
ed.input(special(KeyCode::Esc), &mut ta); ed.input(key('0'), &mut ta); type_chars(&mut ed, &mut ta, "3x"); assert_eq!(ta.lines(), &["def".to_string()]);
}
#[test]
fn vi_ex_wq_submits_and_q_is_eof() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "payload");
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key(':'), &mut ta);
assert_eq!(ed.ex(), Some(""), "ex line is active");
ed.input(key('w'), &mut ta);
ed.input(key('q'), &mut ta);
assert_eq!(ed.input(special(KeyCode::Enter), &mut ta), Step::Continue);
assert!(ed.confirm_prompt().is_some());
let mut ed = vi_editor();
let mut ta = TextArea::default();
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key(':'), &mut ta);
ed.input(key('q'), &mut ta);
assert_eq!(ed.input(special(KeyCode::Enter), &mut ta), Step::Eof);
}
fn vi_buffer(lines: &[&str]) -> (Editor, TextArea<'static>) {
let mut ed = vi_editor();
let mut ta = TextArea::default();
for (i, l) in lines.iter().enumerate() {
if i > 0 {
ed.input(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), &mut ta);
}
type_chars(&mut ed, &mut ta, l);
}
ed.input(special(KeyCode::Esc), &mut ta); ed.input(key('g'), &mut ta);
ed.input(key('g'), &mut ta); (ed, ta)
}
#[test]
fn vi_uppercase_j_joins_line_below() {
let (mut ed, mut ta) = vi_buffer(&["foo", "bar"]);
ed.input(key('J'), &mut ta);
assert_eq!(ta.lines(), &["foo bar".to_string()], "J joins with a space");
ed.input(key('J'), &mut ta);
assert_eq!(ta.lines(), &["foo bar".to_string()]);
}
#[test]
fn vi_count_j_joins_multiple_lines() {
let (mut ed, mut ta) = vi_buffer(&["a", "b", "c"]);
ed.input(key('3'), &mut ta);
ed.input(key('J'), &mut ta);
assert_eq!(ta.lines(), &["a b c".to_string()]);
}
#[test]
fn vi_insert_normal_ctrl_o_runs_one_command() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, "hello"); ed.input(ctrl('o'), &mut ta);
assert_eq!(ed.label(), "vi N", "Ctrl-O drops to NORMAL");
ed.input(key('0'), &mut ta);
assert_eq!(ed.label(), "vi I", "resumes INSERT after one command");
type_chars(&mut ed, &mut ta, "X");
assert_eq!(ta.lines(), &["Xhello".to_string()], "inserted at head");
}
#[test]
fn vi_esc_cancels_incomplete_command() {
let (mut ed, mut ta) = vi_line("hello world");
ed.input(key('d'), &mut ta); ed.input(special(KeyCode::Esc), &mut ta); ed.input(key('w'), &mut ta); assert_eq!(
ta.lines(),
&["hello world".to_string()],
"Esc cancelled the d operator"
);
let (mut ed, mut ta) = vi_line("abcdef");
ed.input(key('3'), &mut ta); ed.input(special(KeyCode::Esc), &mut ta); ed.input(key('x'), &mut ta); assert_eq!(
ta.lines(),
&["bcdef".to_string()],
"Esc cancelled the count"
);
}
#[test]
fn vi_jumplist_back_and_forward() {
let (mut ed, mut ta) = vi_buffer(&["one", "two", "three"]);
assert_eq!(ta.cursor().0, 0, "gg → row 0");
ed.input(ctrl('o'), &mut ta);
assert_eq!(ta.cursor().0, 2, "Ctrl-O → back to row 2");
ed.input(special(KeyCode::Tab), &mut ta);
assert_eq!(ta.cursor().0, 0, "Ctrl-I → forward to row 0");
}
fn vi_line(s: &str) -> (Editor, TextArea<'static>) {
let mut ed = vi_editor();
let mut ta = TextArea::default();
type_chars(&mut ed, &mut ta, s);
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key('0'), &mut ta); (ed, ta)
}
#[test]
fn vi_f_and_semicolon_and_comma_char_search() {
let (mut ed, mut ta) = vi_line("a.b.c.d");
ed.input(key('f'), &mut ta);
ed.input(key('.'), &mut ta);
assert_eq!(ta.cursor().1, 1, "f. → first dot");
ed.input(key(';'), &mut ta);
assert_eq!(ta.cursor().1, 3, "; → next dot");
ed.input(key(','), &mut ta);
assert_eq!(ta.cursor().1, 1, ", → previous dot");
}
#[test]
fn vi_t_and_capital_f_char_search() {
let (mut ed, mut ta) = vi_line("abcXdef");
ed.input(key('t'), &mut ta);
ed.input(key('X'), &mut ta);
assert_eq!(ta.cursor().1, 2, "tX → col before X");
ed.input(key('$'), &mut ta);
ed.input(key('F'), &mut ta);
ed.input(key('X'), &mut ta);
assert_eq!(ta.cursor().1, 3, "FX → onto X");
}
#[test]
fn vi_operator_motions_dw_d_dollar_d0_yy() {
let (mut ed, mut ta) = vi_line("foo bar baz");
ed.input(key('d'), &mut ta);
ed.input(key('w'), &mut ta);
assert_eq!(ta.lines(), &["bar baz".to_string()], "dw");
let (mut ed, mut ta) = vi_line("keep DROP this");
ed.input(key('f'), &mut ta);
ed.input(key('D'), &mut ta); ed.input(key('d'), &mut ta);
ed.input(key('$'), &mut ta);
assert_eq!(ta.lines(), &["keep ".to_string()], "d$");
let (mut ed, mut ta) = vi_line("alpha beta");
ed.input(key('f'), &mut ta);
ed.input(key('b'), &mut ta); ed.input(key('d'), &mut ta);
ed.input(key('0'), &mut ta);
assert_eq!(ta.lines(), &["beta".to_string()], "d0 deletes to BOL");
let (mut ed, mut ta) = vi_line("dup");
ed.input(key('y'), &mut ta);
ed.input(key('y'), &mut ta);
ed.input(key('p'), &mut ta);
assert!(
ta.lines().iter().filter(|l| l.contains("dup")).count() >= 1,
"yy+p yanks and pastes the line"
);
}
#[test]
fn mode_idiomatic_exit_keys() {
let mut ed = emacs_editor();
let mut ta = TextArea::default();
assert_eq!(
ed.input(ctrl('x'), &mut ta),
Step::Continue,
"C-x arms prefix"
);
assert_eq!(ed.input(ctrl('c'), &mut ta), Step::Eof, "C-x C-c → exit");
let mut ed = emacs_editor();
let mut ta = TextArea::default();
ed.input(ctrl('x'), &mut ta);
type_chars(&mut ed, &mut ta, "a"); assert_eq!(ta.lines(), &["a".to_string()]);
assert_eq!(
ed.input(ctrl('c'), &mut ta),
Step::Continue,
"bare C-c abandons the line, does not exit"
);
assert!(buffer_is_empty(&ta), "bare C-c cleared the line");
let mut ed = nano_editor();
let mut ta = TextArea::default();
assert_eq!(ed.input(ctrl('x'), &mut ta), Step::Eof, "nano ^X → exit");
let mut ed = vi_editor();
let mut ta = TextArea::default();
assert_eq!(
ed.input(ctrl('x'), &mut ta),
Step::Continue,
"vi C-x → no exit"
);
}
#[test]
fn mode_idiomatic_help_keys_queue_a_cheatsheet() {
let mut ed = nano_editor();
let mut ta = TextArea::default();
assert_eq!(ed.input(ctrl('g'), &mut ta), Step::Continue);
assert!(ed.take_msg().unwrap().starts_with("nano"));
let mut ed = emacs_editor();
let mut ta = TextArea::default();
assert_eq!(ed.input(ctrl('h'), &mut ta), Step::Continue);
assert!(ed.take_msg().unwrap().starts_with("emacs"));
let mut ed = vi_editor();
let mut ta = TextArea::default();
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key(':'), &mut ta);
for c in "help".chars() {
ed.input(key(c), &mut ta);
}
ed.input(special(KeyCode::Enter), &mut ta);
assert!(ed.take_msg().unwrap().starts_with("vi"));
let mut ed = emacs_editor();
let mut ta = TextArea::default();
ed.input(ctrl('g'), &mut ta);
assert!(ed.take_msg().is_none());
}
#[test]
fn vi_colon_jumps_queues_a_note() {
let mut ed = vi_editor();
let mut ta = TextArea::default();
ed.input(special(KeyCode::Esc), &mut ta);
ed.input(key(':'), &mut ta);
for c in "jumps".chars() {
ed.input(key(c), &mut ta);
}
ed.input(special(KeyCode::Enter), &mut ta);
assert!(ed.take_msg().is_some(), ":jumps queued a scrollback note");
assert!(ed.take_msg().is_none(), "note is one-shot");
}
#[serial_test::serial(real_fs)]
#[test]
fn history_appends_unsaved_entries_to_file() {
let dir = tempfile::tempdir().unwrap();
let hp = dir.path().join("history");
let mut s = RichSurface::new(Some(hp.clone())).unwrap();
s.add_history("alpha");
s.add_history("multi\nline");
s.save_history();
let contents = std::fs::read_to_string(&hp).unwrap();
assert!(contents.contains("alpha"));
assert!(
contents.contains("multi line"),
"newlines flattened to keep one entry per line"
);
s.save_history();
assert_eq!(std::fs::read_to_string(&hp).unwrap(), contents);
}
#[test]
fn history_without_path_is_a_noop() {
let mut s = RichSurface::new(None).unwrap();
s.add_history("ephemeral");
s.save_history(); }
}