use std::io::stdout;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use anyhow::{Result, bail};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers,
};
#[cfg(unix)]
use ratatui::crossterm::event::{
EnableBracketedPaste, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use ratatui::crossterm::execute;
#[cfg(unix)]
use ratatui::crossterm::terminal::supports_keyboard_enhancement;
use ratatui::crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use uuid::Uuid;
use crate::app::events::{AppCommand, AppEvent, BackgroundKind, ClipboardImage, TaskList};
use crate::features::spellcheck::{SpellChecker, dict};
use crate::screens::changes::{ChangesIntent, ChangesScreen};
use crate::screens::chat::{ChatIntent, ChatScreen};
use crate::screens::chat_list::{ChatListIntent, ChatListScreen};
use crate::screens::search::{SearchIntent, SearchScreen};
use crate::screens::self_model::{SelfModelIntent, SelfModelScreen};
use crate::screens::settings::{SettingsIntent, SettingsScreen};
use crate::screens::tasks::{TasksIntent, TasksScreen};
use crate::shared::theme::Palette;
use crate::shared::ui::dim_background;
use crate::widgets::help_dialog::{
self, DEFAULT_HELP_TAB, HelpContext, HelpKeyOutcome, HelpSection, HelpState, HelpTab,
};
use crate::widgets::status_bar::EscTarget;
enum ActiveScreen {
Chat,
ChatList(Box<ChatListScreen>),
Settings(Box<SettingsScreen>),
SelfModel(Box<SelfModelScreen>),
Search(Box<SearchScreen>),
Changes(Box<ChangesScreen>),
Tasks(Box<TasksScreen>),
}
impl ActiveScreen {
fn is_chat(&self) -> bool {
matches!(self, ActiveScreen::Chat)
}
fn set_theme(&mut self, palette: Palette, loc: &'static crate::shared::i18n::Locale) {
match self {
ActiveScreen::ChatList(list) => {
list.set_palette(palette);
list.set_loc(loc);
}
ActiveScreen::SelfModel(view) => {
view.set_palette(palette);
view.set_loc(loc);
}
ActiveScreen::Search(search) => {
search.set_palette(palette);
search.set_loc(loc);
}
ActiveScreen::Changes(changes) => {
changes.set_palette(palette);
changes.set_loc(loc);
}
ActiveScreen::Tasks(tasks) => {
tasks.set_palette(palette);
tasks.set_loc(loc);
}
ActiveScreen::Chat | ActiveScreen::Settings(_) => {}
}
}
fn handle_paste(&mut self, chat: &mut ChatScreen, text: &str) {
match self {
ActiveScreen::Settings(settings) => settings.handle_paste(text),
ActiveScreen::Chat => chat.handle_paste(text),
ActiveScreen::ChatList(list) => list.handle_paste(text),
ActiveScreen::SelfModel(view) => view.handle_paste(text),
ActiveScreen::Search(_) | ActiveScreen::Changes(_) | ActiveScreen::Tasks(_) => {}
}
}
}
enum Back {
Search {
screen: Box<SearchScreen>,
chat: Uuid,
},
Link { origin: Uuid, chat: Uuid },
Tasks {
screen: Box<TasksScreen>,
chat: Uuid,
},
}
impl Back {
fn chat(&self) -> Uuid {
match self {
Back::Search { chat, .. } | Back::Link { chat, .. } | Back::Tasks { chat, .. } => *chat,
}
}
}
struct HelpOverlay {
open: Option<HelpState>,
last_tab: HelpTab,
}
impl HelpOverlay {
fn new() -> Self {
Self {
open: None,
last_tab: DEFAULT_HELP_TAB,
}
}
fn open_for(&mut self, context: HelpContext) {
self.open = Some(match context {
HelpContext::Chat => HelpState::open(self.last_tab),
other => HelpState::open_at(other),
});
}
fn close(&mut self) {
if let Some(state) = self.open.take() {
self.last_tab = state.tab;
}
}
}
pub(super) static HELP_SECTIONS: [&HelpSection; 8] = [
&help_dialog::GLOBAL,
&crate::screens::chat::HELP_SECTION,
&crate::widgets::chat_list::HELP_SECTION,
&crate::screens::settings::HELP_SECTION,
&crate::screens::self_model::HELP_SECTION,
&crate::screens::changes::HELP_SECTION,
&crate::screens::tasks::HELP_SECTION,
&crate::screens::search::HELP_SECTION,
];
fn help_context(active: &ActiveScreen) -> HelpContext {
match active {
ActiveScreen::Chat => HelpContext::Chat,
ActiveScreen::ChatList(_) => HelpContext::ChatList,
ActiveScreen::Settings(_) => HelpContext::Settings,
ActiveScreen::SelfModel(_) => HelpContext::SelfModel,
ActiveScreen::Search(_) => HelpContext::Search,
ActiveScreen::Changes(_) => HelpContext::Changes,
ActiveScreen::Tasks(_) => HelpContext::Tasks,
}
}
fn esc_target(back: &Option<Back>) -> EscTarget {
match back {
Some(Back::Search { .. }) => EscTarget::SearchResults,
Some(Back::Link { .. }) => EscTarget::PreviousChat,
Some(Back::Tasks { .. }) => EscTarget::Tasks,
None => EscTarget::ChatList,
}
}
const TICK: Duration = Duration::from_millis(50);
pub fn run(
cmd_tx: UnboundedSender<AppCommand>,
evt_rx: UnboundedReceiver<AppEvent>,
dict_dir: PathBuf,
bundled_dict_dir: Option<PathBuf>,
personal: PathBuf,
log_dir: PathBuf,
background_query: Option<crate::shared::osc11::Pending>,
) -> Result<()> {
let mut terminal = ratatui::init();
crate::shared::theme::set_detected_background(crate::shared::osc11::resolve(background_query));
#[cfg(windows)]
let _ = execute!(
stdout(),
ratatui::crossterm::terminal::SetTitle(format!(
"{} v{}",
crate::shared::credits::APP_NAME,
env!("CARGO_PKG_VERSION"),
)),
);
#[cfg(unix)]
{
let _ = execute!(stdout(), EnableBracketedPaste);
let reported = supports_keyboard_enhancement().unwrap_or(false);
if reported {
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
crate::shared::keys::set_modified_enter_reported(reported);
}
#[cfg(windows)]
crate::shared::keys::set_modified_enter_reported(true);
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(
stdout(),
EndSynchronizedUpdate,
DisableMouseCapture,
DisableBracketedPaste
);
#[cfg(unix)]
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
prev_hook(info);
}));
let result = run_loop(
&mut terminal,
&cmd_tx,
evt_rx,
dict_dir,
bundled_dict_dir,
personal,
log_dir,
);
let _ = execute!(
stdout(),
EndSynchronizedUpdate,
DisableMouseCapture,
DisableBracketedPaste
);
#[cfg(unix)]
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
ratatui::restore();
let _ = cmd_tx.send(AppCommand::Quit);
result
}
struct SpellLoader {
dict_dir: PathBuf,
bundled_dir: Option<PathBuf>,
personal: PathBuf,
tx: Sender<(u64, SpellChecker)>,
rx: Receiver<(u64, SpellChecker)>,
applied: Option<(bool, Vec<String>)>,
generation: u64,
}
impl SpellLoader {
fn new(dict_dir: PathBuf, bundled_dir: Option<PathBuf>, personal: PathBuf) -> Self {
let (tx, rx) = channel();
Self {
dict_dir,
bundled_dir,
personal,
tx,
rx,
applied: None,
generation: 0,
}
}
fn maybe_reload(&mut self, enabled: bool, selected: &[String]) {
let changed = self
.applied
.as_ref()
.is_none_or(|(e, s)| *e != enabled || s.as_slice() != selected);
if !changed {
return;
}
self.generation += 1;
let generation = self.generation;
let (dir, bundled, personal, tx) = (
self.dict_dir.clone(),
self.bundled_dir.clone(),
self.personal.clone(),
self.tx.clone(),
);
let selected = selected.to_vec();
let sel_for_thread = selected.clone();
std::thread::spawn(move || {
let checker = dict::load(
&dir,
bundled.as_deref(),
&personal,
enabled,
&sel_for_thread,
);
let _ = tx.send((generation, checker));
});
self.applied = Some((enabled, selected));
}
fn poll(&self) -> Option<SpellChecker> {
let mut latest = None;
while let Ok((generation, checker)) = self.rx.try_recv() {
if generation == self.generation {
latest = Some(checker);
}
}
latest
}
}
enum Drain {
Idle,
Applied,
BackendGone,
}
fn drain_events(rx: &mut UnboundedReceiver<AppEvent>, mut apply: impl FnMut(AppEvent)) -> Drain {
let mut applied = false;
loop {
match rx.try_recv() {
Ok(event) => {
apply(event);
applied = true;
}
Err(TryRecvError::Empty) => {
return if applied { Drain::Applied } else { Drain::Idle };
}
Err(TryRecvError::Disconnected) => return Drain::BackendGone,
}
}
}
fn run_loop(
terminal: &mut DefaultTerminal,
cmd_tx: &UnboundedSender<AppCommand>,
mut evt_rx: UnboundedReceiver<AppEvent>,
dict_dir: PathBuf,
bundled_dict_dir: Option<PathBuf>,
personal: PathBuf,
log_dir: PathBuf,
) -> Result<()> {
let mut screen = ChatScreen::new();
let mut active = ActiveScreen::Chat;
let mut back: Option<Back> = None;
let mut clipboard: Option<arboard::Clipboard> = None;
let mut spell = SpellLoader::new(dict_dir, bundled_dict_dir, personal);
let mut help = HelpOverlay::new();
let mut quit = false;
let mut dirty = true;
let mut last_screen = std::mem::discriminant(&active);
let mut last_overlay = false;
let mut backend_gone = false;
while !quit {
let drained = drain_events(&mut evt_rx, |event| {
apply_event(
&mut screen,
&mut active,
&mut back,
&mut clipboard,
cmd_tx,
event,
);
});
match drained {
Drain::Idle => {}
Drain::Applied => dirty = true,
Drain::BackendGone => {
backend_gone = true;
break;
}
}
if spellcheck_upkeep(&mut spell, &mut screen, &mut active) {
dirty = true;
}
if spinner_frame_needed(&active, &screen) {
dirty = true;
refresh_task_rows(&active, cmd_tx);
}
if let Some(draft) = screen.take_dirty_draft() {
let _ = cmd_tx.send(AppCommand::SetDraft(draft));
}
if dirty {
draw_frame(
terminal,
&mut screen,
&mut active,
&mut help,
&back,
&mut last_screen,
&mut last_overlay,
)?;
dirty = false;
}
if handle_input_tick(
&mut screen,
&mut active,
&mut help,
&mut back,
cmd_tx,
&mut clipboard,
&mut dirty,
)? {
quit = true;
}
}
if let Some(draft) = screen.take_dirty_draft() {
let _ = cmd_tx.send(AppCommand::SetDraft(draft));
}
if backend_gone {
let loc = screen.loc();
bail!(
"{}",
loc.tf(
"cli.err.backend_gone",
&[("path", &log_dir.display().to_string())]
)
);
}
Ok(())
}
fn spellcheck_upkeep(
spell: &mut SpellLoader,
screen: &mut ChatScreen,
active: &mut ActiveScreen,
) -> bool {
let mut dirty = false;
if let Some((enabled, selected)) = screen.spell_config() {
spell.maybe_reload(enabled, selected);
}
if let Some(checker) = spell.poll() {
screen.set_spellchecker(checker);
dirty = true;
}
if active.is_chat() && screen.maybe_recheck_spelling() {
dirty = true;
}
if let ActiveScreen::ChatList(list) = active
&& let Some(spell) = screen.spellchecker()
&& list.recheck_spelling(spell)
{
dirty = true;
}
dirty
}
fn spinner_frame_needed(active: &ActiveScreen, screen: &ChatScreen) -> bool {
match active {
ActiveScreen::Chat => screen.is_rag_active() || screen.is_impersonating(),
ActiveScreen::Tasks(tasks) => tasks.needs_repaint(),
_ => false,
}
}
fn refresh_task_rows(active: &ActiveScreen, cmd_tx: &UnboundedSender<AppCommand>) {
if let ActiveScreen::Tasks(tasks) = active
&& tasks.has_app_task_running()
{
let _ = cmd_tx.send(AppCommand::RequestTasks);
}
}
fn draw_frame(
terminal: &mut DefaultTerminal,
screen: &mut ChatScreen,
active: &mut ActiveScreen,
help: &mut HelpOverlay,
back: &Option<Back>,
last_screen: &mut std::mem::Discriminant<ActiveScreen>,
last_overlay: &mut bool,
) -> Result<()> {
screen.set_esc_target(esc_target(back));
let requested = if matches!(active, ActiveScreen::Chat) {
screen.take_full_redraw()
} else {
false
};
let now_screen = std::mem::discriminant(active);
let switched = now_screen != *last_screen;
*last_screen = now_screen;
let overlay = help.open.is_some();
let overlay_toggled = overlay != *last_overlay;
*last_overlay = overlay;
if requested || switched || overlay_toggled {
crate::shared::ui::prime_full_redraw(terminal.current_buffer_mut());
terminal.swap_buffers();
}
let _ = execute!(stdout(), BeginSynchronizedUpdate);
let palette = screen.palette();
let loc = screen.loc();
let drawn = terminal.draw(|frame| {
match active {
ActiveScreen::Chat => screen.render(frame),
ActiveScreen::ChatList(list) => list.render(frame),
ActiveScreen::Settings(settings) => settings.render(frame),
ActiveScreen::SelfModel(view) => view.render(frame),
ActiveScreen::Search(search) => search.render(frame),
ActiveScreen::Changes(changes) => changes.render(frame),
ActiveScreen::Tasks(tasks) => tasks.render(frame),
}
if let Some(state) = help.open.as_mut() {
dim_background(frame, &palette);
help_dialog::render_help(frame, state, &HELP_SECTIONS, &palette, loc);
}
});
let _ = execute!(stdout(), EndSynchronizedUpdate);
drawn?;
Ok(())
}
fn handle_input_tick(
screen: &mut ChatScreen,
active: &mut ActiveScreen,
help: &mut HelpOverlay,
back: &mut Option<Back>,
cmd_tx: &UnboundedSender<AppCommand>,
clipboard: &mut Option<arboard::Clipboard>,
dirty: &mut bool,
) -> Result<bool> {
if !event::poll(TICK)? {
return Ok(false);
}
*dirty = true;
let mut batch = Vec::new();
collect_press(&mut batch, event::read()?);
while event::poll(Duration::ZERO)? {
collect_press(&mut batch, event::read()?);
}
if batch.len() >= PASTE_BURST {
while event::poll(PASTE_GAP)? {
collect_press(&mut batch, event::read()?);
}
}
Ok(process_input_batch(
batch, screen, active, help, back, cmd_tx, clipboard,
))
}
mod clipboard;
mod dispatch;
mod input;
use self::{clipboard::*, dispatch::*, input::*};
#[cfg(test)]
mod tests;