use std::collections::VecDeque;
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Color, Modifier, Style};
use tui_textarea::{Input, Key, TextArea, WrapMode};
use crate::clipboard::PastedImage;
use crate::session::SessionMetadata;
use crate::tools::utils::ConfirmationType;
use super::event::{ExitSummary, Job, StatusSnapshot};
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub struct App {
pub textarea: TextArea<'static>,
pub busy: bool,
pub busy_label: String,
pub queue: VecDeque<Job>,
pub spinner_tick: usize,
pub busy_since: Option<Instant>,
pub picker: Option<Picker>,
pub confirmation: Option<ConfirmationPrompt>,
pub model_label: String,
pub should_quit: bool,
pub exit_summary: Option<ExitSummary>,
pub status: Option<StatusSnapshot>,
pub pasted_images: Vec<PastedImage>,
pub input_history: VecDeque<String>,
pub history_cursor: Option<usize>,
}
pub const INPUT_HISTORY_CAP: usize = 100;
pub struct Picker {
pub sessions: Vec<SessionMetadata>,
pub cursor: usize,
}
pub struct ConfirmationPrompt {
pub prompt: String,
pub choices: Vec<String>,
pub cursor: usize,
pub default_index: usize,
pub kind: ConfirmationType,
pub responder: std::sync::mpsc::Sender<usize>,
}
impl App {
pub fn new(model_label: String) -> Self {
let mut textarea = TextArea::default();
style_textarea(&mut textarea);
Self {
textarea,
busy: false,
busy_label: String::new(),
queue: VecDeque::new(),
spinner_tick: 0,
busy_since: None,
picker: None,
model_label,
should_quit: false,
exit_summary: None,
status: None,
confirmation: None,
pasted_images: Vec::new(),
input_history: VecDeque::new(),
history_cursor: None,
}
}
pub fn is_safe_mode(&self) -> bool {
matches!(
self.status.as_ref().map(|s| s.mode),
Some(super::event::Mode::Safe)
)
}
pub fn remember_submitted(&mut self, text: &str) {
if text.is_empty() {
return;
}
if self.input_history.back().map(String::as_str) == Some(text) {
self.history_cursor = None;
return;
}
self.input_history.push_back(text.to_string());
while self.input_history.len() > INPUT_HISTORY_CAP {
self.input_history.pop_front();
}
self.history_cursor = None;
}
pub fn history_prev(&mut self) {
if self.input_history.is_empty() {
return;
}
let next = match self.history_cursor {
None => self.input_history.len() - 1,
Some(0) => 0,
Some(i) => i - 1,
};
self.history_cursor = Some(next);
self.load_history_entry(next);
}
pub fn history_next(&mut self) {
let Some(cursor) = self.history_cursor else {
return;
};
if cursor + 1 >= self.input_history.len() {
self.history_cursor = None;
self.clear_input();
return;
}
let next = cursor + 1;
self.history_cursor = Some(next);
self.load_history_entry(next);
}
fn load_history_entry(&mut self, index: usize) {
let Some(entry) = self.input_history.get(index).cloned() else {
return;
};
self.clear_input();
self.textarea.insert_str(entry);
}
pub fn start_busy(&mut self, label: impl Into<String>) {
self.busy = true;
self.busy_label = label.into();
self.busy_since = Some(Instant::now());
self.spinner_tick = 0;
}
pub fn finish_busy(&mut self) {
self.busy = false;
self.busy_label.clear();
self.busy_since = None;
}
pub fn spinner_frame(&self) -> &'static str {
SPINNER_FRAMES[self.spinner_tick % SPINNER_FRAMES.len()]
}
pub fn advance_spinner(&mut self) {
self.spinner_tick = (self.spinner_tick + 1) % SPINNER_FRAMES.len();
}
pub fn input_text(&self) -> String {
self.textarea.lines().join("\n")
}
pub fn clear_input(&mut self) {
self.textarea = TextArea::default();
style_textarea(&mut self.textarea);
}
pub fn handle_textarea_input(&mut self, key: KeyEvent) {
let input = key_to_input(key);
if matches!(input.key, Key::Enter) && !input.shift && !input.alt {
return;
}
self.textarea.input(input);
}
}
fn key_to_input(key: KeyEvent) -> Input {
let tkey = match key.code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Backspace => Key::Backspace,
KeyCode::Enter => Key::Enter,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Tab => Key::Tab,
KeyCode::BackTab => Key::Tab,
KeyCode::Delete => Key::Delete,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Esc => Key::Esc,
KeyCode::F(n) => Key::F(n),
_ => Key::Null,
};
Input {
key: tkey,
ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
alt: key.modifiers.contains(KeyModifiers::ALT),
shift: key.modifiers.contains(KeyModifiers::SHIFT),
}
}
fn style_textarea(textarea: &mut TextArea<'_>) {
textarea.set_cursor_line_style(Style::default());
textarea.set_placeholder_text("Ask anything… (Enter to send, Shift+Enter for newline)");
textarea.set_placeholder_style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
);
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
App::new("test-model".into())
}
#[test]
fn busy_lifecycle() {
let mut a = app();
assert!(!a.busy);
a.start_busy("processing");
assert!(a.busy);
assert_eq!(a.busy_label, "processing");
assert!(a.busy_since.is_some());
a.finish_busy();
assert!(!a.busy);
assert!(a.busy_label.is_empty());
assert!(a.busy_since.is_none());
}
#[test]
fn spinner_advances_cyclically() {
let mut a = app();
let start = a.spinner_tick;
for _ in 0..SPINNER_FRAMES.len() {
a.advance_spinner();
}
assert_eq!(a.spinner_tick, start);
}
#[test]
fn clear_input_empties_textarea() {
let mut a = app();
a.textarea.insert_str("hello\nworld");
assert!(!a.input_text().is_empty());
a.clear_input();
assert_eq!(a.input_text(), "");
}
#[test]
fn textarea_has_soft_wrap_enabled() {
let a = app();
assert_eq!(a.textarea.wrap_mode(), WrapMode::WordOrGlyph);
}
#[test]
fn wrap_mode_persists_after_clear_input() {
let mut a = app();
a.textarea.insert_str("one two three");
a.clear_input();
assert_eq!(a.textarea.wrap_mode(), WrapMode::WordOrGlyph);
}
#[test]
fn wrap_mode_persists_after_history_load() {
let mut a = app();
a.remember_submitted("hi");
a.history_prev();
assert_eq!(a.textarea.wrap_mode(), WrapMode::WordOrGlyph);
}
#[test]
fn shift_enter_inserts_newline_through_textarea_handler() {
let mut a = app();
a.textarea.insert_str("hello");
let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
a.handle_textarea_input(shift_enter);
assert_eq!(a.textarea.lines(), &["hello", ""]);
}
#[test]
fn is_safe_mode_reads_from_status_snapshot() {
use crate::repl::tui::event::{Mode, StatusSnapshot};
let mut a = app();
assert!(!a.is_safe_mode(), "defaults to non-safe when status unset");
a.status = Some(StatusSnapshot {
model: "m".into(),
mode: Mode::Safe,
reasoning: String::new(),
input_tokens: 0,
output_tokens: 0,
});
assert!(a.is_safe_mode());
a.status.as_mut().unwrap().mode = Mode::Normal;
assert!(!a.is_safe_mode());
}
#[test]
fn history_push_and_navigate() {
let mut a = app();
a.remember_submitted("one");
a.remember_submitted("two");
a.remember_submitted("three");
assert_eq!(a.history_cursor, None);
a.history_prev();
assert_eq!(a.input_text(), "three");
a.history_prev();
assert_eq!(a.input_text(), "two");
a.history_prev();
assert_eq!(a.input_text(), "one");
a.history_prev();
assert_eq!(a.input_text(), "one");
a.history_next();
assert_eq!(a.input_text(), "two");
a.history_next();
assert_eq!(a.input_text(), "three");
a.history_next();
assert_eq!(a.input_text(), "");
assert_eq!(a.history_cursor, None);
}
#[test]
fn history_deduplicates_consecutive_duplicates() {
let mut a = app();
a.remember_submitted("foo");
a.remember_submitted("foo");
a.remember_submitted("foo");
assert_eq!(a.input_history.len(), 1);
}
#[test]
fn history_ignores_empty_strings() {
let mut a = app();
a.remember_submitted("");
assert!(a.input_history.is_empty());
}
#[test]
fn history_caps_at_limit() {
let mut a = app();
for i in 0..(INPUT_HISTORY_CAP + 20) {
a.remember_submitted(&format!("msg {i}"));
}
assert_eq!(a.input_history.len(), INPUT_HISTORY_CAP);
assert_eq!(a.input_history.front().map(String::as_str), Some("msg 20"));
}
#[test]
fn status_snapshot_roundtrip() {
use crate::repl::tui::event::{Mode, StatusSnapshot};
let mut a = app();
assert!(a.status.is_none());
a.status = Some(StatusSnapshot {
model: "claude-opus-4-6".into(),
mode: Mode::Safe,
reasoning: "thinking: 10000 tok".into(),
input_tokens: 123,
output_tokens: 456,
});
let s = a.status.as_ref().unwrap();
assert_eq!(s.mode.label(), "safe");
assert_eq!(s.model, "claude-opus-4-6");
assert_eq!(s.input_tokens, 123);
}
}