Skip to main content

ripl/
lib.rs

1pub(crate) mod app;
2pub mod aura;
3pub mod config;
4pub mod providers;
5pub(crate) mod scaffold;
6pub mod session;
7pub mod speech;
8pub mod theme;
9pub(crate) mod ui;
10
11use std::io;
12use std::path::PathBuf;
13use std::process::Child;
14use std::sync::atomic::{AtomicI32, Ordering};
15use std::sync::mpsc;
16use std::sync::Arc;
17use std::thread;
18use std::time::{Duration, Instant};
19
20use color_eyre::eyre::Result;
21use crossterm::{
22    event,
23    event::{
24        DisableMouseCapture, EnableMouseCapture,
25        KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
26    },
27    execute,
28    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
29};
30use ratatui::{backend::CrosstermBackend, Terminal};
31
32use crate::app::{App, AppMode};
33use crate::providers::{build_provider, resolve_provider, Message, Role};
34use crate::config::{scaffold_bootstrap_enabled, resolve_fish_voice_id, resolve_stt_mode, resolve_tts_mode};
35
36// Global ambient PID — readable from the SIGTERM handler (which can't capture closures).
37static AMBIENT_PID: AtomicI32 = AtomicI32::new(0);
38
39// ─── RunOptions ───────────────────────────────────────────────────────────────
40
41/// Configuration passed to [`run_in_terminal`] by the caller.
42///
43/// `provider` — the chat backend (Claude, GPT, Ouracle, …).  When `None`,
44/// RIPL falls back to `~/.ripl/config.toml`.
45///
46/// `label` — optional status-bar label (e.g. `"Ouracle"`).
47///
48/// `ambient_cmd` — optional path to an ambient audio runner script/binary.
49/// RIPL will spawn it at startup and kill it on exit or SIGTERM.
50///
51/// `voice_id` — optional Fish TTS voice ID, overrides config.
52pub struct RunOptions {
53    pub provider: Option<Arc<dyn providers::Provider>>,
54    pub label: Option<String>,
55    pub ambient_cmd: Option<PathBuf>,
56    pub voice_id: Option<String>,
57    pub scaffold: bool,
58}
59
60impl Default for RunOptions {
61    fn default() -> Self {
62        RunOptions { provider: None, label: None, ambient_cmd: None, voice_id: None, scaffold: true }
63    }
64}
65
66// ─── Public entry points ──────────────────────────────────────────────────────
67
68/// Run RIPL using the provider resolved from `~/.ripl/config.toml` or env vars.
69pub fn run() -> Result<()> {
70    with_terminal(|t| run_in_terminal(t, RunOptions::default()))
71}
72
73/// Run RIPL with a specific provider.
74pub fn run_with_provider(provider: Arc<dyn providers::Provider>, label: Option<String>) -> Result<()> {
75    with_terminal(|t| run_in_terminal(t, RunOptions { provider: Some(provider), label, ..Default::default() }))
76}
77
78/// Set up the terminal (raw mode, alternate screen, mouse, kitty keyboard) and
79/// run `f` inside it, restoring everything on exit, panic, or SIGTERM.
80pub fn with_terminal<F>(f: F) -> Result<()>
81where
82    F: FnOnce(&mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>,
83{
84    // Restore terminal on panic.
85    let default_hook = std::panic::take_hook();
86    std::panic::set_hook(Box::new(move |info| {
87        restore_terminal();
88        default_hook(info);
89    }));
90
91    // Restore terminal + kill ambient on SIGTERM (cargo watch / system kill).
92    unsafe {
93        libc::signal(libc::SIGTERM, sigterm_handler as *const () as libc::sighandler_t);
94    }
95
96    enable_raw_mode()?;
97    let mut stdout = io::stdout();
98    // Kitty keyboard protocol — Press/Repeat/Release needed for PTT.
99    // Silently ignored by terminals that don't support it.
100    let _ = execute!(
101        stdout,
102        PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
103    );
104    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
105    let backend = CrosstermBackend::new(stdout);
106    let mut terminal = Terminal::new(backend)?;
107    terminal.clear()?;
108    let res = f(&mut terminal);
109
110    // Restore unconditionally — don't let an error in cleanup skip later steps.
111    let _ = disable_raw_mode();
112    let _ = execute!(
113        terminal.backend_mut(),
114        PopKeyboardEnhancementFlags,
115        LeaveAlternateScreen,
116        DisableMouseCapture
117    );
118    let _ = terminal.show_cursor();
119
120    // Kill ambient before exit so it doesn't outlive us.
121    kill_ambient();
122
123    // Flush stdout — process::exit skips Rust's BufWriter, so escape sequences
124    // (LeaveAlternateScreen etc.) must be flushed explicitly before we exit.
125    let _ = io::Write::flush(&mut io::stdout());
126
127    std::process::exit(if res.is_ok() { 0 } else { 1 });
128}
129
130/// Run the RIPL event loop inside an already-initialised terminal.
131/// Use [`with_terminal`] to set one up, or call [`run`] / [`run_with_provider`]
132/// which handle both steps together.
133pub fn run_in_terminal(
134    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
135    opts: RunOptions,
136) -> Result<()> {
137    let cfg = config::Config::load();
138    let provider = opts.provider.or_else(|| build_provider(&cfg));
139
140    let mut app = App::new();
141    app.provider = provider.clone();
142    if std::env::var("RIPL_DEV").is_ok() {
143        app.dev_mode = true;
144    }
145    app.tts_mode = match resolve_tts_mode(&cfg).as_str() {
146        "fish" => speech::TtsMode::Fish,
147        "espeak" => speech::TtsMode::Espeak,
148        "off" => speech::TtsMode::Off,
149        _ => speech::TtsMode::Say,
150    };
151    app.stt_mode = match resolve_stt_mode(&cfg).as_str() {
152        "fish" => speech::SttMode::Fish,
153        "off" => speech::SttMode::Off,
154        _ => speech::SttMode::Whisper,
155    };
156    // Caller-supplied voice_id takes precedence over config.
157    app.tts_voice_id = opts.voice_id.or_else(|| resolve_fish_voice_id(&cfg));
158    app.push_to_talk = cfg.speech.as_ref().and_then(|s| s.push_to_talk).unwrap_or(true);
159
160    if provider.is_some() {
161        app.mode = AppMode::Ready;
162    }
163
164    app.provider_label = opts.label.or_else(|| {
165        resolve_provider(&cfg).map(|r| format!("{} / {}", r.kind_name(), r.model))
166    });
167
168    if let Some(ctx) = scaffold::load_context() {
169        app.conversation.push(Message { role: Role::System, content: ctx });
170    }
171    if let Some(cache) = session::load() {
172        let mut last_assistant: Option<String> = None;
173        for msg in &cache.conversation {
174            let label = match msg.role {
175                Role::User => "You",
176                Role::Assistant => {
177                    last_assistant = Some(msg.content.clone());
178                    "Assistant"
179                }
180                Role::System => continue,
181            };
182            app.conversation.push(msg.clone());
183            app.messages.push(format!("{label}: {}", msg.content));
184        }
185        if let Some(content) = last_assistant {
186            app.greet(content);
187        }
188    }
189    if opts.scaffold && scaffold_bootstrap_enabled(&cfg) {
190        match scaffold::detect_scaffold() {
191            scaffold::ScaffoldState::AutoWrite => {
192                let _ = scaffold::apply_scaffold(scaffold::ScaffoldChoice::Overwrite);
193            }
194            scaffold::ScaffoldState::Prompt => {
195                app.scaffold_prompt = Some(scaffold::ScaffoldChoice::Leave);
196            }
197            scaffold::ScaffoldState::NoneNeeded => {}
198        }
199    }
200
201    // Spawn ambient audio if requested.  The guard kills it when dropped (normal
202    // exit).  The global PID lets the SIGTERM handler kill it too.
203    let _ambient = opts.ambient_cmd.and_then(|cmd| spawn_ambient(&cmd));
204
205    let (resp_tx, resp_rx) = mpsc::channel();
206    let mut last_tick = Instant::now();
207    let tick_rate = Duration::from_millis(100);
208
209    loop {
210        terminal.draw(|frame| ui::draw(frame, &mut app))?;
211
212        let timeout = tick_rate
213            .checked_sub(last_tick.elapsed())
214            .unwrap_or(Duration::from_secs(0));
215
216        if event::poll(timeout)? {
217            let ev = event::read()?;
218            if app.scaffold_prompt.is_some() {
219                app.handle_scaffold_input(&ev);
220                if app.scaffold_prompt.is_some() {
221                    continue;
222                }
223            }
224            if app.on_event(&ev) {
225                return Ok(());
226            }
227        }
228
229        if app.mouse_capture_dirty {
230            app.mouse_capture_dirty = false;
231            if app.mouse_capture {
232                let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
233            } else {
234                let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
235            }
236        }
237
238        if let Some(choice) = app.take_scaffold_choice() {
239            let _ = scaffold::apply_scaffold(choice);
240        }
241
242        if let Some(_line) = app.take_outgoing() {
243            if let Some(p) = provider.clone() {
244                let tx = resp_tx.clone();
245                let messages = app.conversation.clone();
246                thread::spawn(move || {
247                    p.stream(&messages, tx);
248                });
249            } else {
250                app.messages.push("No provider configured. Run: ripl pair anthropic".to_string());
251                app.mode = AppMode::Setup;
252            }
253        }
254
255        if let Some(cmd) = app.take_outgoing_command() {
256            if let Some(p) = provider.clone() {
257                let tx = resp_tx.clone();
258                thread::spawn(move || {
259                    p.handle_command(&cmd, tx);
260                });
261            }
262        }
263
264        let mut should_exit = false;
265        while let Ok(resp) = resp_rx.try_recv() {
266            if matches!(resp, crate::providers::ApiResponse::Exit) {
267                should_exit = true;
268            }
269            app.handle_api_response(resp);
270        }
271        if should_exit {
272            return Ok(());
273        }
274
275        if app.session_dirty {
276            session::save(&session::SessionCache {
277                conversation: app.conversation.clone(),
278                provider: None,
279                model: None,
280            });
281            app.session_dirty = false;
282        }
283
284        if last_tick.elapsed() >= tick_rate {
285            app.on_tick(last_tick.elapsed());
286            last_tick = Instant::now();
287        }
288    }
289}
290
291// ─── Ambient audio ────────────────────────────────────────────────────────────
292
293struct AmbientGuard(Child);
294
295impl Drop for AmbientGuard {
296    fn drop(&mut self) {
297        let _ = self.0.kill();
298        AMBIENT_PID.store(0, Ordering::Relaxed);
299    }
300}
301
302fn spawn_ambient(cmd: &PathBuf) -> Option<AmbientGuard> {
303    if !cmd.exists() {
304        return None;
305    }
306    // .js scripts are run with bun; everything else is executed directly.
307    let child = if cmd.extension().and_then(|e| e.to_str()) == Some("js") {
308        let bun = std::env::var("BUN_PATH")
309            .unwrap_or_else(|_| "bun".to_string());
310        std::process::Command::new(bun)
311            .arg(cmd)
312            .stdin(std::process::Stdio::null())
313            .stdout(std::process::Stdio::null())
314            .stderr(std::process::Stdio::null())
315            .spawn()
316    } else {
317        std::process::Command::new(cmd)
318            .stdin(std::process::Stdio::null())
319            .stdout(std::process::Stdio::null())
320            .stderr(std::process::Stdio::null())
321            .spawn()
322    };
323    match child {
324        Ok(c) => {
325            AMBIENT_PID.store(c.id() as i32, Ordering::Relaxed);
326            Some(AmbientGuard(c))
327        }
328        Err(_) => None,
329    }
330}
331
332// ─── Signal handling ──────────────────────────────────────────────────────────
333
334fn restore_terminal() {
335    let _ = disable_raw_mode();
336    let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
337}
338
339fn kill_ambient() {
340    let pid = AMBIENT_PID.load(Ordering::Relaxed);
341    if pid > 0 {
342        unsafe { libc::kill(pid, libc::SIGTERM); }
343        AMBIENT_PID.store(0, Ordering::Relaxed);
344    }
345}
346
347extern "C" fn sigterm_handler(_: libc::c_int) {
348    restore_terminal();
349    kill_ambient();
350    std::process::exit(0);
351}