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::backend::Backend;
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 ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::{DefaultTerminal, Terminal};
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::paths::Locations;
use crate::shared::theme::Palette;
use crate::shared::ui::{self, MinSize, SPINNER_STEP, WayOut, 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,
locations: Locations,
}
impl HelpOverlay {
fn new(locations: Locations) -> Self {
Self {
open: None,
last_tab: DEFAULT_HELP_TAB,
locations,
}
}
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);
const _: () = assert!(
SPINNER_STEP.as_millis() - TICK.as_millis() > 100,
"a spinner frame must leave the terminal more than 100 ms of quiet"
);
pub(crate) fn enable_key_modes() -> Option<bool> {
#[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);
Some(reported)
}
#[cfg(windows)]
{
crate::shared::keys::set_modified_enter_reported(true);
None
}
}
#[allow(clippy::too_many_arguments)]
pub fn run(
cmd_tx: UnboundedSender<AppCommand>,
evt_rx: UnboundedReceiver<AppEvent>,
dict_dir: PathBuf,
bundled_dict_dir: Option<PathBuf>,
personal: PathBuf,
log_dir: PathBuf,
locations: Locations,
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"),
)),
);
enable_key_modes();
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,
locations,
);
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,
}
}
}
#[allow(clippy::too_many_arguments)] 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,
locations: Locations,
) -> 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(locations);
let mut quit = false;
let mut dirty = true;
let mut drawn = Drawn::nothing_yet(&active);
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 drawn,
)?;
dirty = false;
}
if handle_input_tick(
&mut screen,
&mut active,
&mut help,
&mut back,
cmd_tx,
&mut clipboard,
&mut dirty,
drawn.placeholder,
)? {
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.spinner_due(),
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);
}
}
struct Drawn {
screen: std::mem::Discriminant<ActiveScreen>,
overlay: bool,
canvas: Color,
placeholder: Option<WayOut>,
}
impl Drawn {
fn nothing_yet(active: &ActiveScreen) -> Self {
Self {
screen: std::mem::discriminant(active),
overlay: false,
canvas: Color::Reset,
placeholder: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CanvasRepaint {
None,
Erase,
Resize,
}
fn canvas_repaint(last: Color, canvas: Color, resized: bool) -> CanvasRepaint {
if resized && canvas != Color::Reset {
CanvasRepaint::Resize
} else if canvas != last {
CanvasRepaint::Erase
} else {
CanvasRepaint::None
}
}
fn terminal_resized(terminal: &mut DefaultTerminal) -> bool {
let known = terminal.get_frame().area();
terminal
.size()
.is_ok_and(|now| (now.width, now.height) != (known.width, known.height))
}
fn erase_to_canvas(
terminal: &mut DefaultTerminal,
canvas: Color,
repaint: CanvasRepaint,
) -> std::io::Result<()> {
use std::io::Write;
let mut out = stdout();
out.write_all(ui::set_background(canvas).as_bytes())?;
let erased = match repaint {
CanvasRepaint::Resize => out.flush().and_then(|()| terminal.autoresize()),
CanvasRepaint::Erase | CanvasRepaint::None => out.write_all(ui::ERASE_SCREEN.as_bytes()),
};
let reset = out
.write_all(ui::RESET_STYLE.as_bytes())
.and_then(|()| out.flush());
erased.and(reset)
}
fn set_colour_output(palette: &Palette) {
ratatui::crossterm::style::force_color_output(!palette.mono);
}
fn front_palette(active: &ActiveScreen, screen: &ChatScreen) -> Palette {
match active {
ActiveScreen::Settings(settings) => settings.palette(),
_ => screen.palette(),
}
}
fn draw_frame(
terminal: &mut DefaultTerminal,
screen: &mut ChatScreen,
active: &mut ActiveScreen,
help: &mut HelpOverlay,
back: &Option<Back>,
last: &mut Drawn,
) -> 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;
let cramped = terminal.size().is_ok_and(|size| {
too_small(
Rect::new(0, 0, size.width, size.height),
active,
screen,
help,
)
.is_some()
});
let cramped_toggled = cramped != last.placeholder.is_some();
let palette = front_palette(active, screen);
let repaint = canvas_repaint(
last.canvas,
palette.canvas,
palette.canvas != Color::Reset && terminal_resized(terminal),
);
last.canvas = palette.canvas;
set_colour_output(&palette);
if requested
|| switched
|| overlay_toggled
|| cramped_toggled
|| repaint == CanvasRepaint::Erase
{
ui::prime_full_redraw(terminal.current_buffer_mut());
terminal.swap_buffers();
}
let _ = execute!(stdout(), BeginSynchronizedUpdate);
let erased = match repaint {
CanvasRepaint::None => Ok(()),
_ => erase_to_canvas(terminal, palette.canvas, repaint),
};
let drawn = present(terminal, screen, active, help, &palette);
if let Ok(placeholder) = drawn {
last.placeholder = placeholder;
}
let _ = execute!(stdout(), EndSynchronizedUpdate);
erased?;
drawn?;
Ok(())
}
fn present<B: Backend>(
terminal: &mut Terminal<B>,
screen: &mut ChatScreen,
active: &mut ActiveScreen,
help: &mut HelpOverlay,
palette: &Palette,
) -> Result<Option<WayOut>, B::Error> {
if help.open.is_none() {
let mut placeholder = None;
terminal.draw(|frame| {
placeholder = compose_frame(frame, screen, active, help, palette);
})?;
return Ok(placeholder);
}
terminal.autoresize()?;
let placeholder = compose_frame(&mut terminal.get_frame(), screen, active, help, palette);
terminal.apply_buffer()?;
Ok(placeholder)
}
fn too_small(
area: Rect,
active: &ActiveScreen,
screen: &ChatScreen,
help: &HelpOverlay,
) -> Option<(MinSize, WayOut)> {
let mut need = match active {
ActiveScreen::Chat => screen.min_size(area),
ActiveScreen::ChatList(list) => list.min_size(),
ActiveScreen::Settings(settings) => settings.min_size(),
ActiveScreen::SelfModel(view) => view.min_size(),
ActiveScreen::Search(search) => search.min_size(),
ActiveScreen::Changes(changes) => changes.min_size(area),
ActiveScreen::Tasks(tasks) => tasks.min_size(),
};
if help.open.is_some() {
need = need.max(help_dialog::min_size(screen.loc()));
}
let way_out = if help.open.is_some() || !active.is_chat() || screen.has_popup() {
WayOut::EscOrQuit
} else {
WayOut::Quit
};
(!need.fits(area)).then_some((need, way_out))
}
fn compose_frame(
frame: &mut ratatui::Frame,
screen: &mut ChatScreen,
active: &mut ActiveScreen,
help: &mut HelpOverlay,
palette: &Palette,
) -> Option<WayOut> {
let loc = screen.loc();
if let Some((need, way_out)) = too_small(frame.area(), active, screen, help) {
ui::render_too_small(frame, palette, loc, need, way_out);
ui::finish_frame(frame.buffer_mut(), palette);
return Some(way_out);
}
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, &help.locations, palette, loc);
}
ui::finish_frame(frame.buffer_mut(), palette);
None
}
#[allow(clippy::too_many_arguments)]
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,
placeholder: Option<WayOut>,
) -> Result<bool> {
if !event::poll(TICK)? {
return Ok(false);
}
*dirty = true;
let batch = read_batch()?;
Ok(route_batch(
placeholder,
batch,
screen,
active,
help,
back,
cmd_tx,
clipboard,
))
}
pub(crate) fn read_batch() -> Result<Vec<Event>> {
read_batch_from(|| Ok(event::read()?), |wait| Ok(event::poll(wait)?))
}
fn read_batch_from(
mut read: impl FnMut() -> Result<Event>,
mut poll: impl FnMut(Duration) -> Result<bool>,
) -> Result<Vec<Event>> {
let mut batch = Vec::new();
collect_press(&mut batch, read()?);
while poll(Duration::ZERO)? {
collect_press(&mut batch, read()?);
}
if batch.len() >= PASTE_BURST {
while poll(PASTE_GAP)? {
collect_press(&mut batch, read()?);
}
}
Ok(batch)
}
mod clipboard;
mod dispatch;
mod input;
pub(crate) use self::input::{Chunk, chunk_batch};
use self::{clipboard::*, dispatch::*, input::*};
#[cfg(test)]
mod demo_reel;
#[cfg(test)]
mod small_window_tests;
#[cfg(test)]
mod tests;