pub mod app;
pub mod brand;
pub mod editor;
pub mod front;
pub mod keys;
pub mod look;
pub mod palette;
pub mod render;
pub mod session;
pub mod text;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, EventStream, KeyCode,
KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use futures::StreamExt;
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::Position;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};
use ratatui::{Terminal, TerminalOptions, Viewport};
use tokio::sync::mpsc;
use app::{Action, App, UiEvent};
use render::LiveView;
use session::ToSession;
use crate::args::Opts;
use crate::exit::Fail;
const COMMIT_CHUNK: usize = 100;
const FRAME: Duration = Duration::from_millis(16);
const HISTORY_ENTRIES: usize = 500;
pub struct Tracked<B: Backend> {
inner: B,
pos: std::rc::Rc<std::cell::Cell<Option<Position>>>,
}
impl<B: Backend> Backend for Tracked<B> {
fn draw<'a, I>(&mut self, content: I) -> std::io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
{
let mut last = None;
let cells: Vec<_> = content.inspect(|(x, y, _)| last = Some(Position { x: *x, y: *y })).collect();
self.inner.draw(cells.into_iter())?;
if let Some(p) = last {
self.pos.set(Some(Position { x: p.x.saturating_add(1), y: p.y }));
}
Ok(())
}
fn append_lines(&mut self, n: u16) -> std::io::Result<()> {
self.inner.append_lines(n)?;
if let Some(p) = self.pos.get() {
let rows = self.inner.size().map(|s| s.height).unwrap_or(u16::MAX);
self.pos.set(Some(Position { x: p.x, y: p.y.saturating_add(n).min(rows.saturating_sub(1)) }));
}
Ok(())
}
fn hide_cursor(&mut self) -> std::io::Result<()> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> std::io::Result<()> {
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> std::io::Result<Position> {
match self.pos.get() {
Some(p) => Ok(p),
None => {
let p = self.inner.get_cursor_position()?;
self.pos.set(Some(p));
Ok(p)
}
}
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> std::io::Result<()> {
let p = position.into();
self.inner.set_cursor_position(p)?;
self.pos.set(Some(p));
Ok(())
}
fn clear(&mut self) -> std::io::Result<()> {
self.inner.clear()
}
fn clear_region(&mut self, clear_type: ratatui::backend::ClearType) -> std::io::Result<()> {
self.inner.clear_region(clear_type)
}
fn size(&self) -> std::io::Result<ratatui::layout::Size> {
self.inner.size()
}
fn window_size(&mut self) -> std::io::Result<ratatui::backend::WindowSize> {
self.inner.window_size()
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
pub struct Screen<B: Backend> {
terminal: Terminal<Tracked<B>>,
make: Box<dyn FnMut() -> B>,
height: u16,
pos: std::rc::Rc<std::cell::Cell<Option<Position>>>,
}
impl<B: Backend> Screen<B> {
pub fn new(mut make: Box<dyn FnMut() -> B>, height: u16) -> std::io::Result<Self> {
let pos = std::rc::Rc::new(std::cell::Cell::new(None));
let backend = Tracked { inner: make(), pos: pos.clone() };
let terminal = Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(height) })?;
Ok(Screen { terminal, make, height, pos })
}
pub fn height(&self) -> u16 {
self.height
}
pub fn width(&self) -> u16 {
self.terminal.size().map(|s| s.width).unwrap_or(80)
}
pub fn rows(&self) -> u16 {
self.terminal.size().map(|s| s.height).unwrap_or(24)
}
pub fn set_height(&mut self, h: u16) -> std::io::Result<()> {
let h = h.max(1);
if h == self.height {
return Ok(());
}
self.terminal.clear()?;
let backend = Tracked { inner: (self.make)(), pos: self.pos.clone() };
self.terminal = Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(h) })?;
self.height = h;
Ok(())
}
pub fn commit(&mut self, lines: Vec<Line<'static>>) -> std::io::Result<()> {
let rows = text::wrap(lines, self.width());
for chunk in rows.chunks(COMMIT_CHUNK) {
let chunk = chunk.to_vec();
self.terminal.insert_before(chunk.len() as u16, |buf| Paragraph::new(chunk).render(buf.area, buf))?;
}
Ok(())
}
pub fn draw(&mut self, view: &LiveView) -> std::io::Result<()> {
let pad = (self.height as usize).saturating_sub(view.lines.len());
let mut lines = vec![Line::default(); pad];
lines.extend(view.lines.iter().cloned());
self.terminal.draw(|f| {
let area = f.area();
f.render_widget(Paragraph::new(lines), area);
if let Some((x, y)) = view.cursor {
let y = y + pad as u16;
if x < area.width && y < area.height {
f.set_cursor_position((area.x + x, area.y + y));
}
}
})?;
Ok(())
}
pub fn clear(&mut self) -> std::io::Result<()> {
self.terminal.clear()
}
#[cfg(test)]
pub fn backend(&self) -> &B {
&self.terminal.backend().inner
}
}
pub struct Full<B: Backend> {
terminal: Terminal<B>,
transcript: Vec<Line<'static>>,
wrapped: Vec<Line<'static>>,
wrapped_w: u16,
pub scroll: usize,
max_scroll: usize,
info: front::FrontInfo,
}
impl<B: Backend> Full<B> {
pub fn new(backend: B, info: front::FrontInfo) -> std::io::Result<Self> {
let terminal = Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Fullscreen })?;
Ok(Full { terminal, transcript: vec![], wrapped: vec![], wrapped_w: 0, scroll: 0, max_scroll: 0, info })
}
pub fn width(&self) -> u16 {
self.terminal.size().map(|s| s.width).unwrap_or(80)
}
pub fn rows(&self) -> u16 {
self.terminal.size().map(|s| s.height).unwrap_or(24)
}
pub fn commit(&mut self, lines: Vec<Line<'static>>) {
let w = self.width();
let rows = text::wrap(lines.clone(), w);
if self.scroll > 0 {
self.scroll += rows.len();
}
if w == self.wrapped_w {
self.wrapped.extend(rows);
}
self.transcript.extend(lines);
}
pub fn scroll_by(&mut self, rows: isize) {
self.scroll = (self.scroll as isize + rows).clamp(0, self.max_scroll as isize) as usize;
}
pub fn draw(&mut self, app: &App) -> std::io::Result<()> {
let size = self.terminal.size()?;
if size.width != self.wrapped_w {
self.wrapped = text::wrap(self.transcript.clone(), size.width);
self.wrapped_w = size.width;
}
let page = front::front_page(&app.theme, app.look, &self.info, size.width as usize);
let (view, max) = render::full_view(app, size.width, size.height, &self.wrapped, &page, self.scroll);
self.max_scroll = max;
self.scroll = self.scroll.min(max);
self.terminal.draw(|f| {
let area = f.area();
f.render_widget(Paragraph::new(view.lines.clone()), area);
if let Some((x, y)) = view.cursor {
if x < area.width && y < area.height {
f.set_cursor_position((area.x + x, area.y + y));
}
}
})?;
Ok(())
}
pub fn clear(&mut self) -> std::io::Result<()> {
self.terminal.clear()
}
pub fn transcript_text(&self, cols: u16) -> Vec<String> {
text::wrap(self.transcript.clone(), cols).iter().map(brand::ansi).collect()
}
}
enum Display {
Full(Box<Full<CrosstermBackend<std::io::Stdout>>>),
Inline(Screen<CrosstermBackend<std::io::Stdout>>),
}
impl Display {
fn clear(&mut self) -> std::io::Result<()> {
match self {
Display::Full(f) => f.clear(),
Display::Inline(s) => s.clear(),
}
}
}
fn full_screen() -> bool {
std::env::var("KODACT_TUI").map(|v| !v.trim().eq_ignore_ascii_case("inline")).unwrap_or(true)
}
static ENHANCED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static FULL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn keyboard_protocol() -> bool {
ENHANCED.load(std::sync::atomic::Ordering::SeqCst)
}
fn osc52(text: &str) -> String {
use base64::Engine as _;
format!("\x1b]52;c;{}\x07", base64::engine::general_purpose::STANDARD.encode(text))
}
fn restore() {
let mut out = std::io::stdout();
if ENHANCED.swap(false, std::sync::atomic::Ordering::SeqCst) {
let _ = crossterm::execute!(out, PopKeyboardEnhancementFlags);
}
if FULL.swap(false, std::sync::atomic::Ordering::SeqCst) {
let _ = crossterm::execute!(out, DisableMouseCapture, crossterm::terminal::LeaveAlternateScreen);
}
let _ = crossterm::execute!(out, DisableBracketedPaste, crossterm::cursor::Show);
let _ = crossterm::terminal::disable_raw_mode();
let _ = out.flush();
}
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
restore();
}
}
fn setup(full: bool) -> std::io::Result<Guard> {
crossterm::terminal::enable_raw_mode()?;
let guard = Guard;
let mut out = std::io::stdout();
crossterm::execute!(out, EnableBracketedPaste)?;
if full {
crossterm::execute!(out, crossterm::terminal::EnterAlternateScreen, EnableMouseCapture)?;
FULL.store(true, std::sync::atomic::Ordering::SeqCst);
}
if crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false) {
crossterm::execute!(out, PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES))?;
ENHANCED.store(true, std::sync::atomic::Ordering::SeqCst);
}
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore();
prev(info);
}));
Ok(guard)
}
pub fn history_path() -> PathBuf {
kodact_config::state_dir().join("history.jsonl")
}
pub fn load_history(path: &Path, cwd: &str) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else { return vec![] };
let mut out: Vec<String> = text
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| v["cwd"] == cwd)
.filter_map(|v| v["text"].as_str().map(str::to_string))
.collect();
if out.len() > HISTORY_ENTRIES {
out.drain(..out.len() - HISTORY_ENTRIES);
}
out
}
pub fn append_history(path: &Path, cwd: &str, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut o = std::fs::OpenOptions::new();
o.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
let mut f = o.open(path)?;
writeln!(f, "{}", serde_json::json!({"text": text, "cwd": cwd}))
}
const MAX_FILES: usize = 20_000;
pub fn project_files(root: &Path) -> Vec<String> {
let mut out = vec![];
for e in ignore::WalkBuilder::new(root).build().flatten() {
let Ok(rel) = e.path().strip_prefix(root) else { continue };
if rel.as_os_str().is_empty() {
continue;
}
let mut p = rel.to_string_lossy().replace('\\', "/");
if e.file_type().is_some_and(|t| t.is_dir()) {
p.push('/');
}
out.push(p);
if out.len() >= MAX_FILES {
break;
}
}
out
}
fn walk_files(root: String, ui: mpsc::UnboundedSender<UiEvent>) {
tokio::task::spawn_blocking(move || {
let _ = ui.send(UiEvent::Files(project_files(Path::new(&root))));
});
}
pub async fn run(prompt: Option<String>, o: Opts) -> Result<i32, Fail> {
let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<UiEvent>();
let theme = text::Theme::named("auto", crate::term::get().color, palette::Depth::detect());
let cols = crossterm::terminal::size().map(|(c, _)| c as usize).unwrap_or(80);
let (driver, warnings) = match session::build(&o, ui_tx.clone()).await {
Ok(built) => built,
Err(f) if f.first_run => {
for l in brand::first_run(&theme, kodact_core::VERSION, &f.message, cols) {
println!("{}", brand::ansi(&l));
}
return Ok(f.code);
}
Err(f) => return Err(f),
};
let live = driver.live();
let cwd = driver.info.cwd.display().to_string();
let persist = !o.no_session_persistence;
let hist_path = history_path();
let mut app = App::new(theme, load_history(&hist_path, &cwd));
let (keymap, key_warnings) = keys::Keymap::load();
app.keymap = keymap;
let warnings: Vec<String> = warnings.into_iter().chain(key_warnings).collect();
let provider = driver.engine.provider();
let endpoint = endpoint_label(provider.base_url().as_deref(), provider.origin().map(|o| o.backend));
let recent = kodact_core::commands::recent_sessions(&driver, 3);
let full = full_screen();
let branch = kodact_git::current_branch(&driver.info.cwd);
let info = front::FrontInfo {
version: kodact_core::VERSION.to_string(),
model: driver.handle().model(),
endpoint: endpoint.clone(),
folder: short_path(&cwd),
branch: branch.clone(),
mcp: driver.info.init.mcp_servers.iter().map(|s| (s.name.clone(), s.status.clone())).collect(),
tools: driver.info.init.tools.len(),
recent: recent.clone(),
};
app.front = true;
app.endpoint = endpoint;
app.branch = branch.unwrap_or_default();
if !full {
let rows = crossterm::terminal::size().map(|(_, r)| r as usize).unwrap_or(24);
app.pending.extend(brand::welcome(
&theme,
&brand::Welcome { recent: &recent[..recent.len().min(1)] },
cols,
rows,
));
}
for w in warnings {
app.on_event(UiEvent::Engine(kodact_engine::EngineEvent::Notice {
level: kodact_engine::NoticeLevel::Warning,
text: w,
}));
}
let (to_session, rx) = mpsc::unbounded_channel::<ToSession>();
walk_files(cwd.clone(), ui_tx.clone());
let files_tx = ui_tx.downgrade();
let mut files_root = cwd.clone();
let task = tokio::spawn(session::run(driver, rx, ui_tx));
let guard = setup(full).map_err(|e| Fail::config(format!("cannot set up the terminal: {e}")))?;
let draw_fail = |e: std::io::Error| Fail::config(format!("cannot draw on the terminal: {e}"));
let mut screen = if full {
Display::Full(Box::new(Full::new(CrosstermBackend::new(std::io::stdout()), info).map_err(draw_fail)?))
} else {
Display::Inline(Screen::new(Box::new(|| CrosstermBackend::new(std::io::stdout())), 1).map_err(draw_fail)?)
};
let send = |text: String, to: &mpsc::UnboundedSender<ToSession>| {
let _ = append_history(&hist_path, &cwd, &text);
let _ = to.send(ToSession::Input(text));
};
if let Some(p) = prompt.filter(|p| !p.trim().is_empty()) {
for a in app.submit(p) {
if let Action::Send(t) = a {
send(t, &to_session);
}
}
}
let mut events = EventStream::new();
let mut tick = tokio::time::interval(render::SPINNER_STEP);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut last_draw = Instant::now() - FRAME;
let mut had_dialog = false;
let mut exiting = false;
loop {
if !app.pending.is_empty() {
match &mut screen {
Display::Inline(s) => s.commit(app.take_pending()).map_err(io_fail)?,
Display::Full(f) => f.commit(app.take_pending()),
}
app.dirty = true;
}
if let Some(text) = app.clipboard.take() {
let mut out = std::io::stdout();
let _ = out.write_all(osc52(&text).as_bytes());
let _ = out.flush();
}
if app.exit {
break;
}
while let Some(t) = app.next_queued() {
send(t, &to_session);
}
if !app.status.cwd.is_empty() && app.status.cwd != files_root {
files_root = app.status.cwd.clone();
if let Some(tx) = files_tx.upgrade() {
walk_files(files_root.clone(), tx);
}
}
let now = Instant::now();
if app.dirty && now.duration_since(last_draw) >= FRAME {
match &mut screen {
Display::Inline(screen) => {
let max = screen.rows().saturating_sub(1).max(1);
let view = render::live_view(&app, screen.width(), max);
let want = (view.lines.len() as u16).min(max);
let dialog_closed = had_dialog && app.dialog.is_none();
if want > screen.height() || (want < screen.height() && (!app.busy || dialog_closed)) {
screen.set_height(want).map_err(io_fail)?;
}
screen.draw(&view).map_err(io_fail)?;
}
Display::Full(f) => f.draw(&app).map_err(io_fail)?,
}
had_dialog = app.dialog.is_some();
app.dirty = false;
last_draw = now;
}
let hint = app.hint.as_ref().is_some_and(|(_, at)| at.elapsed() < render::HINT_FOR + render::SPINNER_STEP);
let frame_wait = (last_draw + FRAME).saturating_duration_since(Instant::now());
tokio::select! {
ev = events.next() => match ev {
Some(Ok(Event::Key(k))) if k.kind != KeyEventKind::Release
&& app.keymap.translate(k).is_some_and(|k| {
k.code == KeyCode::Char('l') && k.modifiers.contains(KeyModifiers::CONTROL)
}) => {
screen.clear().map_err(io_fail)?;
app.dirty = true;
}
Some(Ok(Event::Mouse(m))) if matches!(screen, Display::Full(_)) => {
if let Display::Full(f) = &mut screen {
match m.kind {
MouseEventKind::ScrollUp => f.scroll_by(3),
MouseEventKind::ScrollDown => f.scroll_by(-3),
_ => {}
}
}
app.dirty = true;
}
Some(Ok(Event::Key(k))) if k.kind != KeyEventKind::Release
&& app.dialog.is_none()
&& matches!(k.code, KeyCode::PageUp | KeyCode::PageDown)
&& matches!(screen, Display::Full(_)) => {
if let Display::Full(f) = &mut screen {
let page = (f.rows() as isize / 2).max(1);
f.scroll_by(if k.code == KeyCode::PageUp { page } else { -page });
}
app.dirty = true;
}
Some(Ok(Event::Resize(..))) => app.dirty = true,
Some(Ok(ev)) => {
for a in app.on_term_event(ev) {
match a {
Action::Send(t) => {
if let Display::Full(f) = &mut screen {
f.scroll = 0;
}
send(t, &to_session)
}
Action::Picker(t) => {
let _ = to_session.send(ToSession::Picker(t));
}
Action::Interrupt => {
live.handle().interrupt();
app.close_dialog_if_stale();
}
Action::SetMode(m) => live.handle().set_permission_mode(m),
Action::Exit => {
let _ = to_session.send(ToSession::Exit);
exiting = true;
}
}
}
if exiting {
break;
}
}
Some(Err(_)) | None => break,
},
ev = ui_rx.recv() => match ev {
Some(ev) => {
app.on_event(ev);
while let Ok(ev) = ui_rx.try_recv() {
app.on_event(ev);
}
}
None => break,
},
_ = tick.tick(), if app.busy || hint => app.dirty = true,
_ = tokio::time::sleep(frame_wait), if app.dirty && !frame_wait.is_zero() => {}
}
}
let transcript = match &mut screen {
Display::Inline(s) => {
let _ = s.commit(app.take_pending());
let _ = s.set_height(1);
let _ = s.clear();
vec![]
}
Display::Full(f) => {
f.commit(app.take_pending());
let cols = f.width();
f.transcript_text(cols)
}
};
drop(screen);
drop(guard);
if !transcript.is_empty() {
let mut out = std::io::stdout();
for l in transcript {
let _ = writeln!(out, "{l}");
}
let _ = out.flush();
}
let _ = to_session.send(ToSession::Exit);
drop(to_session);
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
if persist {
eprintln!("Resume this conversation with: kodact --resume {}", live.session_id());
}
Ok(crate::exit::OK)
}
fn endpoint_label(url: Option<&str>, backend: Option<kodact_api::auth::Backend>) -> String {
if let Some(u) = url {
let rest = u.split_once("://").map(|(_, r)| r).unwrap_or(u);
let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host = host.rsplit('@').next().unwrap_or(host);
if !host.is_empty() {
return host.to_string();
}
}
match backend {
Some(kodact_api::auth::Backend::OpenAi) => "an OpenAI-compatible endpoint".into(),
Some(kodact_api::auth::Backend::Gemini) => "the Gemini API".into(),
_ => "the Messages API".into(),
}
}
pub fn short_path(p: &str) -> String {
kodact_config::short_path(std::path::Path::new(p))
}
fn io_fail(e: std::io::Error) -> Fail {
Fail::config(format!("terminal error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_banner_names_the_endpoint_host_without_credentials() {
use kodact_api::auth::Backend;
assert_eq!(endpoint_label(Some("http://127.0.0.1:11434/v1"), Some(Backend::OpenAi)), "127.0.0.1:11434");
assert_eq!(endpoint_label(Some("https://u:p@gw.example.com/v1?k=x"), None), "gw.example.com");
assert_eq!(endpoint_label(None, Some(Backend::Messages)), "the Messages API");
assert_eq!(endpoint_label(None, Some(Backend::OpenAi)), "an OpenAI-compatible endpoint");
}
use ratatui::backend::TestBackend;
struct AsksOnce {
inner: TestBackend,
asked: std::rc::Rc<std::cell::Cell<u32>>,
}
impl Backend for AsksOnce {
fn draw<'a, I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>>(&mut self, c: I) -> std::io::Result<()> {
self.inner.draw(c)
}
fn append_lines(&mut self, n: u16) -> std::io::Result<()> {
self.inner.append_lines(n)
}
fn hide_cursor(&mut self) -> std::io::Result<()> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> std::io::Result<()> {
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> std::io::Result<Position> {
self.asked.set(self.asked.get() + 1);
if self.asked.get() > 1 {
return Err(std::io::Error::other("The cursor position could not be read within a normal duration"));
}
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<Position>>(&mut self, p: P) -> std::io::Result<()> {
self.inner.set_cursor_position(p)
}
fn clear(&mut self) -> std::io::Result<()> {
self.inner.clear()
}
fn clear_region(&mut self, t: ratatui::backend::ClearType) -> std::io::Result<()> {
self.inner.clear_region(t)
}
fn size(&self) -> std::io::Result<ratatui::layout::Size> {
self.inner.size()
}
fn window_size(&mut self) -> std::io::Result<ratatui::backend::WindowSize> {
self.inner.window_size()
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[test]
fn resizing_the_viewport_never_asks_the_terminal_again() {
let asked = std::rc::Rc::new(std::cell::Cell::new(0));
let a = asked.clone();
let mut s =
Screen::new(Box::new(move || AsksOnce { inner: TestBackend::new(40, 12), asked: a.clone() }), 2).unwrap();
s.draw(&LiveView { lines: vec![Line::from("> ")], cursor: Some((2, 0)) }).unwrap();
s.set_height(9).expect("growing for a picker works");
s.draw(&LiveView { lines: vec![Line::from("picker"); 9], cursor: None }).unwrap();
s.set_height(3).expect("and shrinking back");
s.commit(vec![Line::from("answer")]).unwrap();
assert_eq!(asked.get(), 1, "only the first viewport asked");
}
fn rows(b: &TestBackend) -> Vec<String> {
let buf = b.buffer();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>().trim_end().into()
})
.collect()
}
#[test]
fn commit_wraps_into_scrollback_above_the_viewport() {
let mut s = Screen::new(Box::new(|| TestBackend::new(10, 6)), 2).unwrap();
let view = LiveView { lines: vec![Line::from("> input")], cursor: Some((2, 0)) };
s.draw(&view).unwrap();
s.commit(vec![Line::from("the quick brown fox"), Line::from("end")]).unwrap();
s.draw(&view).unwrap();
let r = rows(s.backend());
let at = r.iter().position(|l| l == "the quick").expect("committed");
assert_eq!(r[at + 1], "brown fox");
assert_eq!(r[at + 2], "end");
assert!(r[at + 3..].iter().any(|l| l == "> input"), "{r:?}");
}
#[test]
fn commit_writes_long_output_in_chunks() {
let mut s = Screen::new(Box::new(|| TestBackend::new(20, 8)), 1).unwrap();
let lines: Vec<Line<'static>> = (0..250).map(|i| Line::from(format!("row {i}"))).collect();
s.commit(lines).unwrap();
let r = rows(s.backend());
assert!(r.iter().any(|l| l == "row 249"), "{r:?}");
assert!(!r.iter().any(|l| l == "row 0"));
}
#[test]
fn history_is_per_directory_and_private() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("state/history.jsonl");
append_history(&p, "/a", "first").unwrap();
append_history(&p, "/b", "elsewhere").unwrap();
append_history(&p, "/a", "multi\nline").unwrap();
assert_eq!(load_history(&p, "/a"), ["first", "multi\nline"]);
assert!(load_history(&d.path().join("none"), "/a").is_empty());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
}
}
#[test]
fn project_files_follow_ignore_rules() {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join("src")).unwrap();
std::fs::create_dir_all(d.path().join("target")).unwrap();
std::fs::write(d.path().join("src/main.rs"), "").unwrap();
std::fs::write(d.path().join("target/out"), "").unwrap();
std::fs::write(d.path().join(".gitignore"), "target/\n").unwrap();
std::process::Command::new("git").arg("init").arg("-q").current_dir(d.path()).status().ok();
let mut files = project_files(d.path());
files.sort();
assert_eq!(files, ["src/", "src/main.rs"]);
}
#[test]
fn osc52_carries_base64() {
assert_eq!(osc52("hi"), "\x1b]52;c;aGk=\x07");
}
}