pub mod app;
pub mod event;
pub mod inline_terminal;
pub mod inline_tui;
pub mod output;
pub mod scrollback;
pub mod sgr;
pub mod ui;
pub mod worker;
use std::fs::OpenOptions;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc as std_mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::time::{Interval, interval};
use crate::commands::{COMMANDS, Command};
use crate::error::Result;
use crate::repl::{Repl, SteerQueue};
use crate::ui::UI;
use app::{App, Picker};
use event::{Job, UiEvent};
use output::OutputCapture;
const TICK_INTERVAL: Duration = Duration::from_millis(90);
const MAX_OUTPUT_BATCH: usize = 256;
struct TerminalGuard {
_private: (),
}
impl TerminalGuard {
fn install() -> std::io::Result<Self> {
crossterm::terminal::enable_raw_mode()?;
use std::io::Write;
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::event::EnableBracketedPaste,
crossterm::event::PushKeyboardEnhancementFlags(
crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
),
);
let _ = std::io::stdout().flush();
Ok(Self { _private: () })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
use std::io::Write;
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::event::PopKeyboardEnhancementFlags,
crossterm::event::DisableBracketedPaste,
);
let _ = std::io::stdout().flush();
let _ = crossterm::terminal::disable_raw_mode();
}
}
pub fn run(mut repl: Repl) -> Result<()> {
let tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
let tty_for_backend = tty.try_clone()?;
let (ui_tx, ui_rx) = mpsc::unbounded_channel::<UiEvent>();
let (job_tx, job_rx) = std_mpsc::channel::<Job>();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|e| crate::error::SofosError::Config(format!("runtime: {}", e)))?;
let _terminal_guard = TerminalGuard::install()?;
let backend = CrosstermBackend::new(tty_for_backend);
let terminal = inline_terminal::Terminal::new(backend)?;
drop(tty);
let mut inline_tui = inline_tui::InlineTui::new(terminal);
let capture = OutputCapture::install(ui_tx.clone())?;
colored::control::set_override(true);
install_confirm_handler(ui_tx.clone());
let interrupt = Arc::new(AtomicBool::new(false));
repl.install_interrupt_flag(Arc::clone(&interrupt));
let steer_queue: SteerQueue = Arc::new(Mutex::new(Vec::new()));
repl.install_steer_queue(Arc::clone(&steer_queue));
let model_label = repl.model_label();
let startup_banner = repl.take_startup_banner();
let worker_handle = worker::spawn(repl, job_rx, ui_tx.clone(), Arc::clone(&interrupt))?;
spawn_input_reader(ui_tx.clone())?;
let mut app = App::new(model_label);
if !startup_banner.is_empty() {
print!("{}", startup_banner);
}
UI::print_welcome();
drop(ui_tx);
let result = runtime.block_on(async {
event_loop(
&mut inline_tui,
&mut app,
ui_rx,
job_tx.clone(),
Arc::clone(&interrupt),
Arc::clone(&steer_queue),
)
.await
});
let _ = job_tx.send(Job::Shutdown);
let _ = worker_handle.thread.join();
drop(capture);
drop(_terminal_guard);
colored::control::unset_override();
if let Some(summary) = app.exit_summary.take() {
UI::display_session_summary(&summary.model, summary.input_tokens, summary.output_tokens);
UI::print_goodbye();
}
result
}
async fn event_loop(
tui: &mut inline_tui::InlineTui,
app: &mut App,
mut ui_rx: UnboundedReceiver<UiEvent>,
job_tx: std_mpsc::Sender<Job>,
interrupt: Arc<AtomicBool>,
steer_queue: SteerQueue,
) -> Result<()> {
let mut tick: Interval = interval(TICK_INTERVAL);
let mut last_size: (u16, u16) = (0, 0);
render_frame(tui, app, &mut last_size)?;
loop {
let event = tokio::select! {
ev = ui_rx.recv() => ev,
_ = tick.tick() => Some(UiEvent::Tick),
};
let Some(first) = event else {
break;
};
let mut current = first;
loop {
match current {
UiEvent::Tick => {
if app.busy && app.confirmation.is_none() {
app.advance_spinner();
}
break;
}
UiEvent::Output { kind: _, text } => {
if crossterm::terminal::size()? != last_size {
render_frame(tui, app, &mut last_size)?;
}
let mut batch: Vec<String> = Vec::with_capacity(32);
batch.push(text);
let mut forwarded: Option<UiEvent> = None;
while batch.len() < MAX_OUTPUT_BATCH {
match ui_rx.try_recv() {
Ok(UiEvent::Output { text, .. }) => batch.push(text),
Ok(other) => {
forwarded = Some(other);
break;
}
Err(_) => break,
}
}
tui.queue_history_lines(batch);
if let Some(next) = forwarded {
current = next;
continue;
}
break;
}
UiEvent::Key(key) => {
if app.confirmation.is_some() {
handle_confirmation_key(app, key);
} else if app.picker.is_some() {
handle_picker_key(app, key, &job_tx);
} else {
handle_idle_key(app, key, &job_tx, &interrupt, &steer_queue);
}
break;
}
UiEvent::Paste(text) => {
if app.confirmation.is_none() && app.picker.is_none() {
app.textarea.insert_str(text);
}
break;
}
UiEvent::Resize => {
break;
}
UiEvent::WorkerBusy(label) => {
app.start_busy(label);
break;
}
UiEvent::WorkerIdle => {
app.finish_busy();
if app.picker.is_none() {
let residual: Vec<String> = std::mem::take(
&mut *steer_queue
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()),
);
if !residual.is_empty() {
let _ = job_tx.send(Job::Message {
text: residual.join("\n\n"),
images: Vec::new(),
});
} else if let Some(next) = app.queue.pop_front() {
let _ = job_tx.send(next);
}
}
break;
}
UiEvent::Status(snapshot) => {
app.status = Some(snapshot);
break;
}
UiEvent::ShowResumePicker(sessions) => {
app.picker = Some(Picker {
sessions,
cursor: 0,
});
break;
}
UiEvent::ConfirmRequest {
prompt,
choices,
default_index,
kind,
responder,
} => {
let initial_cursor =
if matches!(kind, crate::tools::utils::ConfirmationType::Permission) {
0
} else {
default_index.min(choices.len().saturating_sub(1))
};
app.confirmation = Some(app::ConfirmationPrompt {
prompt,
cursor: initial_cursor,
default_index,
choices,
kind,
responder,
});
break;
}
UiEvent::WorkerShutdown(summary) => {
app.exit_summary = Some(summary);
app.should_quit = true;
let mut pending_batch: Vec<String> = Vec::new();
while let Ok(pending) = ui_rx.try_recv() {
if let UiEvent::Output { text, .. } = pending {
pending_batch.push(text);
}
}
if !pending_batch.is_empty() {
tui.queue_history_lines(pending_batch);
}
break;
}
}
}
render_frame(tui, app, &mut last_size)?;
if app.should_quit {
break;
}
}
Ok(())
}
fn render_frame(
tui: &mut inline_tui::InlineTui,
app: &mut App,
last_size: &mut (u16, u16),
) -> Result<()> {
let current_size = crossterm::terminal::size()?;
let desired_height = ui::desired_viewport_height(app, current_size.0);
tui.draw(desired_height, |f| ui::draw(f, app))?;
*last_size = current_size;
Ok(())
}
fn handle_idle_key(
app: &mut App,
key: KeyEvent,
job_tx: &std_mpsc::Sender<Job>,
interrupt: &Arc<AtomicBool>,
steer_queue: &SteerQueue,
) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('c') if ctrl => {
if app.busy {
if interrupt.load(Ordering::SeqCst) {
request_shutdown(app, job_tx);
} else {
interrupt.store(true, Ordering::SeqCst);
}
} else {
request_shutdown(app, job_tx);
}
}
KeyCode::Char('d') if ctrl && !app.busy && app.textarea.is_empty() => {
request_shutdown(app, job_tx);
}
KeyCode::Char('v') if ctrl => {
handle_clipboard_paste(app);
}
KeyCode::Up if alt && !ctrl => {
app.history_prev();
}
KeyCode::Down if alt && !ctrl => {
app.history_next();
}
KeyCode::Esc if app.busy => {
interrupt.store(true, Ordering::SeqCst);
}
KeyCode::Enter if !shift && !alt && !ctrl => {
submit_input(app, job_tx, steer_queue);
}
KeyCode::Tab if !shift && !alt && !ctrl => {
if !try_complete_command(app) {
app.handle_textarea_input(key);
}
}
_ => app.handle_textarea_input(key),
}
}
fn try_complete_command(app: &mut App) -> bool {
let text = app.input_text();
if text.contains('\n') {
return false;
}
let trimmed = text.trim_end();
if !trimmed.starts_with('/') {
return false;
}
let matches: Vec<&'static str> = COMMANDS
.iter()
.copied()
.filter(|cmd| cmd.starts_with(trimmed))
.collect();
match matches.as_slice() {
[] => {}
[single] => {
let delta = &single[trimmed.len()..];
if !delta.is_empty() {
app.textarea.move_cursor(tui_textarea::CursorMove::End);
app.textarea.insert_str(delta);
}
}
many => {
let lcp = longest_common_prefix(many);
if lcp.len() > trimmed.len() {
let delta = &lcp[trimmed.len()..];
app.textarea.move_cursor(tui_textarea::CursorMove::End);
app.textarea.insert_str(delta);
}
}
}
true
}
fn longest_common_prefix(items: &[&'static str]) -> &'static str {
let Some(&first) = items.first() else {
return "";
};
let mut end = first.len();
for item in &items[1..] {
let mut i = 0;
let a = first.as_bytes();
let b = item.as_bytes();
while i < end && i < b.len() && a[i] == b[i] {
i += 1;
}
end = i;
if end == 0 {
break;
}
}
&first[..end]
}
fn handle_clipboard_paste(app: &mut App) {
if let Some(image) = crate::clipboard::get_clipboard_image() {
let idx = app.pasted_images.len();
app.pasted_images.push(image);
let marker = crate::clipboard::marker_for_index(idx);
app.textarea.insert_str(format!("{} ", marker));
return;
}
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(text) = clipboard.get_text() {
if !text.is_empty() {
app.textarea.insert_str(&text);
}
}
}
}
fn request_shutdown(app: &mut App, job_tx: &std_mpsc::Sender<Job>) {
if job_tx.send(Job::Shutdown).is_err() {
app.should_quit = true;
}
}
fn install_confirm_handler(ui_tx: UnboundedSender<UiEvent>) {
let handler = Box::new(
move |prompt: &str,
choices: &[String],
default_index: usize,
kind: crate::tools::utils::ConfirmationType| {
let (reply_tx, reply_rx) = std_mpsc::channel::<usize>();
let event = UiEvent::ConfirmRequest {
prompt: prompt.to_string(),
choices: choices.to_vec(),
default_index,
kind,
responder: reply_tx,
};
if ui_tx.send(event).is_err() {
return default_index;
}
reply_rx.recv().unwrap_or(default_index)
},
);
crate::tools::utils::set_confirm_handler(handler);
}
fn handle_confirmation_key(app: &mut App, key: KeyEvent) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(confirmation) = app.confirmation.as_mut() else {
return;
};
match key.code {
KeyCode::Up => {
if confirmation.cursor > 0 {
confirmation.cursor -= 1;
}
}
KeyCode::Down => {
if confirmation.cursor + 1 < confirmation.choices.len() {
confirmation.cursor += 1;
}
}
KeyCode::Enter => {
if let Some(c) = app.confirmation.take() {
let _ = c.responder.send(c.cursor);
}
}
KeyCode::Esc => {
if let Some(c) = app.confirmation.take() {
let _ = c.responder.send(c.default_index);
}
}
KeyCode::Char('c') if ctrl => {
if let Some(c) = app.confirmation.take() {
let _ = c.responder.send(c.default_index);
}
}
KeyCode::Char(ch) if !ctrl => {
if let Some(idx) = digit_shortcut(ch, confirmation.choices.len()) {
if let Some(c) = app.confirmation.take() {
let _ = c.responder.send(idx);
}
return;
}
if let Some(idx) = letter_shortcut(ch, &confirmation.choices, confirmation.cursor) {
confirmation.cursor = idx;
return;
}
match ch {
'k' if confirmation.cursor > 0 => {
confirmation.cursor -= 1;
}
'j' if confirmation.cursor + 1 < confirmation.choices.len() => {
confirmation.cursor += 1;
}
_ => {}
}
}
_ => {}
}
}
fn digit_shortcut(ch: char, choices_len: usize) -> Option<usize> {
let n = ch.to_digit(10)?;
if n == 0 {
return None;
}
let idx = (n as usize).checked_sub(1)?;
if idx < choices_len { Some(idx) } else { None }
}
fn letter_shortcut(ch: char, choices: &[String], cursor: usize) -> Option<usize> {
let needle = ch.to_ascii_lowercase();
if !needle.is_ascii_alphabetic() {
return None;
}
let n = choices.len();
(1..=n).find_map(|offset| {
let idx = (cursor + offset) % n;
let first = choices[idx].chars().next()?.to_ascii_lowercase();
(first == needle).then_some(idx)
})
}
fn handle_picker_key(app: &mut App, key: KeyEvent, job_tx: &std_mpsc::Sender<Job>) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(picker) = app.picker.as_mut() else {
return;
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if picker.cursor > 0 {
picker.cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if picker.cursor + 1 < picker.sessions.len() {
picker.cursor += 1;
}
}
KeyCode::Enter => {
let id = picker.sessions[picker.cursor].id.clone();
app.picker = None;
let _ = job_tx.send(Job::ResumeSelected(Some(id)));
}
KeyCode::Esc => {
app.picker = None;
let _ = job_tx.send(Job::ResumeSelected(None));
}
KeyCode::Char('c') if ctrl => {
app.picker = None;
let _ = job_tx.send(Job::ResumeSelected(None));
}
_ => {}
}
}
fn submit_input(app: &mut App, job_tx: &std_mpsc::Sender<Job>, steer_queue: &SteerQueue) {
let raw = app.input_text();
let (cleaned, indices) = crate::clipboard::strip_paste_markers(&raw);
if cleaned.is_empty() && indices.is_empty() {
return;
}
app.clear_input();
let pool = std::mem::take(&mut app.pasted_images);
let images: Vec<crate::clipboard::PastedImage> = indices
.into_iter()
.filter_map(|idx| pool.get(idx).cloned())
.collect();
app.remember_submitted(&cleaned);
let command = if images.is_empty() {
Command::from_str(cleaned.trim())
} else {
None
};
let is_command = command.is_some();
let will_steer = app.busy && !is_command && images.is_empty();
use colored::Colorize;
let glyph = if will_steer {
"↑"
} else if app.is_safe_mode() {
"λ:"
} else {
">"
};
let glyph_styled = if will_steer {
glyph.bright_magenta().bold()
} else {
glyph.bright_green().bold()
};
println!("{} {}", glyph_styled, cleaned);
if will_steer {
println!(
" {}",
"queued for delivery before the next tool call".dimmed()
);
}
if !images.is_empty() {
println!(
"{} {} image(s) from clipboard",
"📋".bright_cyan(),
images.len()
);
}
println!();
if let Some(cmd) = command {
let job = Job::Command(cmd);
if app.busy {
app.queue.push_back(job);
} else {
let _ = job_tx.send(job);
}
return;
}
if will_steer {
steer_queue
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(cleaned);
return;
}
let job = Job::Message {
text: cleaned,
images,
};
if app.busy {
app.queue.push_back(job);
} else {
let _ = job_tx.send(job);
}
}
fn spawn_input_reader(tx: UnboundedSender<UiEvent>) -> std::io::Result<()> {
const POLL_TIMEOUT: Duration = Duration::from_millis(50);
thread::Builder::new()
.name("sofos-input".into())
.spawn(move || {
loop {
match crossterm::event::poll(POLL_TIMEOUT) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => break,
}
let event = match crossterm::event::read() {
Ok(e) => e,
Err(_) => break,
};
let ui_event = match event {
Event::Key(k) => UiEvent::Key(k),
Event::Resize(_, _) => UiEvent::Resize,
Event::Paste(s) => UiEvent::Paste(s),
_ => continue,
};
if tx.send(ui_event).is_err() {
break;
}
}
})?;
Ok(())
}