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::{EffortPickerEntry, ExitSummary, Job, ModelPickerEntry, StatusSnapshot};
use super::slash_popup::SlashPopup;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub struct App {
pub textarea: TextArea<'static>,
busy: bool,
pub busy_label: String,
pub queue: VecDeque<Job>,
pub spinner_tick: usize,
pub busy_since: Option<Instant>,
pub picker: Option<Picker>,
pub model_picker: Option<ModelPicker>,
pub effort_picker: Option<EffortPicker>,
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_draft: Option<String>,
pub history_cursor: Option<usize>,
pub slash_popup: SlashPopup,
}
pub const INPUT_HISTORY_CAP: usize = 100;
pub struct Picker {
pub sessions: Vec<SessionMetadata>,
pub cursor: usize,
}
pub struct ModelPicker {
pub entries: Vec<ModelPickerEntry>,
pub cursor: usize,
}
impl ModelPicker {
pub fn new(entries: Vec<ModelPickerEntry>) -> Self {
let cursor = entries
.iter()
.position(|e| e.is_current && e.is_available)
.or_else(|| entries.iter().position(|e| e.is_available))
.unwrap_or(0);
Self { entries, cursor }
}
pub fn move_up(&mut self) {
self.cursor = step_to_available(self.cursor, &self.entries, -1);
}
pub fn move_down(&mut self) {
self.cursor = step_to_available(self.cursor, &self.entries, 1);
}
pub fn selected(&self) -> Option<&ModelPickerEntry> {
self.entries.get(self.cursor)
}
}
fn step_to_available(cursor: usize, entries: &[ModelPickerEntry], direction: i32) -> usize {
if entries.is_empty() {
return 0;
}
let mut idx = cursor as i32 + direction;
while idx >= 0 && (idx as usize) < entries.len() {
let candidate = idx as usize;
if entries[candidate].is_available {
return candidate;
}
idx += direction;
}
cursor
}
pub struct EffortPicker {
pub entries: Vec<EffortPickerEntry>,
pub cursor: usize,
}
impl EffortPicker {
pub fn new(entries: Vec<EffortPickerEntry>) -> Self {
let cursor = entries.iter().position(|e| e.is_current).unwrap_or(0);
Self { entries, cursor }
}
pub fn move_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_down(&mut self) {
if self.cursor + 1 < self.entries.len() {
self.cursor += 1;
}
}
pub fn selected(&self) -> Option<&EffortPickerEntry> {
self.entries.get(self.cursor)
}
}
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_picker: None,
effort_picker: None,
model_label,
should_quit: false,
exit_summary: None,
status: None,
confirmation: None,
pasted_images: Vec::new(),
input_history: VecDeque::new(),
history_draft: None,
history_cursor: None,
slash_popup: SlashPopup::new(),
}
}
pub fn sync_slash_popup(&mut self) {
let text = self.input_text();
self.slash_popup.sync(&text);
}
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;
}
if self.history_cursor.is_none() {
self.history_draft = Some(self.input_text());
}
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;
let draft = self.history_draft.take().unwrap_or_default();
self.clear_input();
if !draft.is_empty() {
self.textarea.insert_str(draft);
}
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 busy(&self) -> bool {
self.busy
}
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);
self.slash_popup.hide();
}
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 && !input.ctrl {
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 ctrl_enter_inserts_newline_through_textarea_handler() {
let mut a = app();
a.textarea.insert_str("hello");
let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
a.handle_textarea_input(ctrl_enter);
assert_eq!(a.textarea.lines(), &["hello", ""]);
}
#[test]
fn alt_enter_inserts_newline_through_textarea_handler() {
let mut a = app();
a.textarea.insert_str("hello");
let alt_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT);
a.handle_textarea_input(alt_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,
cache_read_tokens: 0,
cache_creation_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 model_picker_lands_cursor_on_current_model() {
let entries = vec![
ModelPickerEntry {
name: crate::api::model_info::CLAUDE_OPUS,
description: "flagship",
is_current: false,
is_available: true,
},
ModelPickerEntry {
name: crate::api::model_info::CLAUDE_SONNET,
description: "default",
is_current: true,
is_available: true,
},
ModelPickerEntry {
name: crate::api::model_info::GPT_FLAGSHIP,
description: "openai",
is_current: false,
is_available: true,
},
];
let p = ModelPicker::new(entries);
assert_eq!(p.cursor, 1);
assert_eq!(
p.selected().unwrap().name,
crate::api::model_info::CLAUDE_SONNET
);
}
#[test]
fn model_picker_move_down_skips_disabled_rows() {
let entries = vec![
ModelPickerEntry {
name: "a",
description: "",
is_current: true,
is_available: true,
},
ModelPickerEntry {
name: "b",
description: "",
is_current: false,
is_available: false,
},
ModelPickerEntry {
name: "c",
description: "",
is_current: false,
is_available: true,
},
];
let mut p = ModelPicker::new(entries);
assert_eq!(p.cursor, 0);
p.move_down();
assert_eq!(p.cursor, 2, "down should skip the disabled row at 1");
}
#[test]
fn model_picker_move_up_skips_disabled_rows() {
let entries = vec![
ModelPickerEntry {
name: "a",
description: "",
is_current: false,
is_available: true,
},
ModelPickerEntry {
name: "b",
description: "",
is_current: false,
is_available: false,
},
ModelPickerEntry {
name: "c",
description: "",
is_current: true,
is_available: true,
},
];
let mut p = ModelPicker::new(entries);
assert_eq!(p.cursor, 2);
p.move_up();
assert_eq!(p.cursor, 0, "up should skip the disabled row at 1");
}
#[test]
fn model_picker_navigation_stops_at_list_edges() {
let entries = vec![ModelPickerEntry {
name: "only",
description: "",
is_current: true,
is_available: true,
}];
let mut p = ModelPicker::new(entries);
p.move_down();
assert_eq!(p.cursor, 0);
p.move_up();
assert_eq!(p.cursor, 0);
}
#[test]
fn effort_picker_lands_cursor_on_current_level() {
use crate::api::ReasoningEffort::{High, Low, Medium, Off};
let entries = vec![
EffortPickerEntry {
effort: Off,
is_current: false,
},
EffortPickerEntry {
effort: Low,
is_current: false,
},
EffortPickerEntry {
effort: Medium,
is_current: true,
},
EffortPickerEntry {
effort: High,
is_current: false,
},
];
let p = EffortPicker::new(entries);
assert_eq!(p.cursor, 2);
assert_eq!(p.selected().unwrap().effort, Medium);
}
#[test]
fn effort_picker_navigation_clamps_at_edges() {
use crate::api::ReasoningEffort::{Low, Off};
let entries = vec![
EffortPickerEntry {
effort: Off,
is_current: true,
},
EffortPickerEntry {
effort: Low,
is_current: false,
},
];
let mut p = EffortPicker::new(entries);
assert_eq!(p.cursor, 0);
p.move_up();
assert_eq!(p.cursor, 0);
p.move_down();
assert_eq!(p.cursor, 1);
p.move_down();
assert_eq!(p.cursor, 1);
}
#[test]
fn model_picker_with_all_rows_disabled_keeps_cursor_at_zero() {
let entries = vec![
ModelPickerEntry {
name: "x",
description: "",
is_current: false,
is_available: false,
},
ModelPickerEntry {
name: "y",
description: "",
is_current: false,
is_available: false,
},
];
let mut p = ModelPicker::new(entries);
assert_eq!(p.cursor, 0);
p.move_down();
assert_eq!(p.cursor, 0, "no enabled row to step to");
}
#[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: crate::api::model_info::CLAUDE_OPUS.into(),
mode: Mode::Safe,
reasoning: "thinking: 10000 tok".into(),
input_tokens: 123,
output_tokens: 456,
cache_read_tokens: 0,
cache_creation_tokens: 0,
});
let s = a.status.as_ref().unwrap();
assert_eq!(s.mode.label(), "safe");
assert_eq!(s.model, crate::api::model_info::CLAUDE_OPUS);
assert_eq!(s.input_tokens, 123);
}
}