pub(crate) mod app;
pub mod aura;
pub mod config;
pub mod providers;
pub(crate) mod scaffold;
pub mod session;
pub mod speech;
pub mod theme;
pub(crate) mod ui;
use std::io;
use std::path::PathBuf;
use std::process::Child;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use color_eyre::eyre::Result;
use crossterm::{
event,
event::{
DisableMouseCapture, EnableMouseCapture,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use crate::app::{App, AppMode};
use crate::providers::{build_provider, resolve_provider, Message, Role};
use crate::config::{scaffold_bootstrap_enabled, resolve_fish_voice_id, resolve_stt_mode, resolve_tts_mode};
static AMBIENT_PID: AtomicI32 = AtomicI32::new(0);
pub struct RunOptions {
pub provider: Option<Arc<dyn providers::Provider>>,
pub label: Option<String>,
pub ambient_cmd: Option<PathBuf>,
pub voice_id: Option<String>,
pub scaffold: bool,
}
impl Default for RunOptions {
fn default() -> Self {
RunOptions { provider: None, label: None, ambient_cmd: None, voice_id: None, scaffold: true }
}
}
pub fn run() -> Result<()> {
with_terminal(|t| run_in_terminal(t, RunOptions::default()))
}
pub fn run_with_provider(provider: Arc<dyn providers::Provider>, label: Option<String>) -> Result<()> {
with_terminal(|t| run_in_terminal(t, RunOptions { provider: Some(provider), label, ..Default::default() }))
}
pub fn with_terminal<F>(f: F) -> Result<()>
where
F: FnOnce(&mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>,
{
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
default_hook(info);
}));
unsafe {
libc::signal(libc::SIGTERM, sigterm_handler as *const () as libc::sighandler_t);
}
enable_raw_mode()?;
let mut stdout = io::stdout();
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
);
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let res = f(&mut terminal);
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
PopKeyboardEnhancementFlags,
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = terminal.show_cursor();
kill_ambient();
let _ = io::Write::flush(&mut io::stdout());
std::process::exit(if res.is_ok() { 0 } else { 1 });
}
pub fn run_in_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
opts: RunOptions,
) -> Result<()> {
let cfg = config::Config::load();
let provider = opts.provider.or_else(|| build_provider(&cfg));
let mut app = App::new();
app.provider = provider.clone();
if std::env::var("RIPL_DEV").is_ok() {
app.dev_mode = true;
}
app.tts_mode = match resolve_tts_mode(&cfg).as_str() {
"fish" => speech::TtsMode::Fish,
"espeak" => speech::TtsMode::Espeak,
"off" => speech::TtsMode::Off,
_ => speech::TtsMode::Say,
};
app.stt_mode = match resolve_stt_mode(&cfg).as_str() {
"fish" => speech::SttMode::Fish,
"off" => speech::SttMode::Off,
_ => speech::SttMode::Whisper,
};
app.tts_voice_id = opts.voice_id.or_else(|| resolve_fish_voice_id(&cfg));
app.push_to_talk = cfg.speech.as_ref().and_then(|s| s.push_to_talk).unwrap_or(true);
if provider.is_some() {
app.mode = AppMode::Ready;
}
app.provider_label = opts.label.or_else(|| {
resolve_provider(&cfg).map(|r| format!("{} / {}", r.kind_name(), r.model))
});
if let Some(ctx) = scaffold::load_context() {
app.conversation.push(Message { role: Role::System, content: ctx });
}
if let Some(cache) = session::load() {
let mut last_assistant: Option<String> = None;
for msg in &cache.conversation {
let label = match msg.role {
Role::User => "You",
Role::Assistant => {
last_assistant = Some(msg.content.clone());
"Assistant"
}
Role::System => continue,
};
app.conversation.push(msg.clone());
app.messages.push(format!("{label}: {}", msg.content));
}
if let Some(content) = last_assistant {
app.greet(content);
}
}
if opts.scaffold && scaffold_bootstrap_enabled(&cfg) {
match scaffold::detect_scaffold() {
scaffold::ScaffoldState::AutoWrite => {
let _ = scaffold::apply_scaffold(scaffold::ScaffoldChoice::Overwrite);
}
scaffold::ScaffoldState::Prompt => {
app.scaffold_prompt = Some(scaffold::ScaffoldChoice::Leave);
}
scaffold::ScaffoldState::NoneNeeded => {}
}
}
let _ambient = opts.ambient_cmd.and_then(|cmd| spawn_ambient(&cmd));
let (resp_tx, resp_rx) = mpsc::channel();
let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(100);
loop {
terminal.draw(|frame| ui::draw(frame, &mut app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(Duration::from_secs(0));
if event::poll(timeout)? {
let ev = event::read()?;
if app.scaffold_prompt.is_some() {
app.handle_scaffold_input(&ev);
if app.scaffold_prompt.is_some() {
continue;
}
}
if app.on_event(&ev) {
return Ok(());
}
}
if app.mouse_capture_dirty {
app.mouse_capture_dirty = false;
if app.mouse_capture {
let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
} else {
let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
}
}
if let Some(choice) = app.take_scaffold_choice() {
let _ = scaffold::apply_scaffold(choice);
}
if let Some(_line) = app.take_outgoing() {
if let Some(p) = provider.clone() {
let tx = resp_tx.clone();
let messages = app.conversation.clone();
thread::spawn(move || {
p.stream(&messages, tx);
});
} else {
app.messages.push("No provider configured. Run: ripl pair anthropic".to_string());
app.mode = AppMode::Setup;
}
}
if let Some(cmd) = app.take_outgoing_command() {
if let Some(p) = provider.clone() {
let tx = resp_tx.clone();
thread::spawn(move || {
p.handle_command(&cmd, tx);
});
}
}
let mut should_exit = false;
while let Ok(resp) = resp_rx.try_recv() {
if matches!(resp, crate::providers::ApiResponse::Exit) {
should_exit = true;
}
app.handle_api_response(resp);
}
if should_exit {
return Ok(());
}
if app.session_dirty {
session::save(&session::SessionCache {
conversation: app.conversation.clone(),
provider: None,
model: None,
});
app.session_dirty = false;
}
if last_tick.elapsed() >= tick_rate {
app.on_tick(last_tick.elapsed());
last_tick = Instant::now();
}
}
}
struct AmbientGuard(Child);
impl Drop for AmbientGuard {
fn drop(&mut self) {
let _ = self.0.kill();
AMBIENT_PID.store(0, Ordering::Relaxed);
}
}
fn spawn_ambient(cmd: &PathBuf) -> Option<AmbientGuard> {
if !cmd.exists() {
return None;
}
let child = if cmd.extension().and_then(|e| e.to_str()) == Some("js") {
let bun = std::env::var("BUN_PATH")
.unwrap_or_else(|_| "bun".to_string());
std::process::Command::new(bun)
.arg(cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
} else {
std::process::Command::new(cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
};
match child {
Ok(c) => {
AMBIENT_PID.store(c.id() as i32, Ordering::Relaxed);
Some(AmbientGuard(c))
}
Err(_) => None,
}
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
}
fn kill_ambient() {
let pid = AMBIENT_PID.load(Ordering::Relaxed);
if pid > 0 {
unsafe { libc::kill(pid, libc::SIGTERM); }
AMBIENT_PID.store(0, Ordering::Relaxed);
}
}
extern "C" fn sigterm_handler(_: libc::c_int) {
restore_terminal();
kill_ambient();
std::process::exit(0);
}