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
36static AMBIENT_PID: AtomicI32 = AtomicI32::new(0);
38
39pub 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
66pub fn run() -> Result<()> {
70 with_terminal(|t| run_in_terminal(t, RunOptions::default()))
71}
72
73pub 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
78pub fn with_terminal<F>(f: F) -> Result<()>
81where
82 F: FnOnce(&mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>,
83{
84 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 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 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 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();
122
123 let _ = io::Write::flush(&mut io::stdout());
126
127 std::process::exit(if res.is_ok() { 0 } else { 1 });
128}
129
130pub 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 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 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
291struct 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 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
332fn 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}