mod app;
mod approval;
mod capabilities;
mod diff;
mod runtime;
mod session_log;
mod settings;
#[cfg(test)]
mod test_env;
mod tools;
#[cfg(test)]
mod streaming_tests;
#[cfg(test)]
mod agent_scenarios;
use anyhow::Result;
use app::{App, COMPOSER_VIEWPORT_HEIGHT};
use approval::ApprovalGate;
use clap::Parser;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use everruns_core::message::MessageRole;
use ratatui::backend::CrosstermBackend;
use ratatui::{Terminal, TerminalOptions, Viewport};
use runtime::{BuiltRuntime, ProviderChoice};
use settings::SettingsStore;
use std::io::{self, IsTerminal};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Parser, Debug)]
#[command(
name = "yolop",
version,
about = "Yolop coding agent — embedded terminal agent built on everruns-runtime"
)]
struct Cli {
#[arg(short = 'C', long = "cwd")]
cwd: Option<PathBuf>,
#[arg(long, value_enum)]
provider: Option<ProviderArg>,
#[arg(short, long)]
model: Option<String>,
#[arg(long)]
reasoning_effort: Option<String>,
#[arg(short = 'p', long)]
print: Option<String>,
#[arg(long)]
ask: bool,
#[arg(long)]
session: Option<String>,
#[arg(long)]
session_dir: Option<PathBuf>,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
enum ProviderArg {
Anthropic,
Openai,
Google,
Openrouter,
Ollama,
#[value(name = "llmsim", alias = "sim")]
Sim,
}
fn provider_name_for_arg(arg: ProviderArg) -> &'static str {
match arg {
ProviderArg::Anthropic => "anthropic",
ProviderArg::Openai => "openai",
ProviderArg::Google => "google",
ProviderArg::Openrouter => "openrouter",
ProviderArg::Ollama => "ollama",
ProviderArg::Sim => "llmsim",
}
}
fn pick_provider(cli: &Cli, settings: &SettingsStore) -> ProviderChoice {
let snapshot = settings.snapshot();
let base = if let Some(arg) = cli.provider {
ProviderChoice::default_for_provider_name(provider_name_for_arg(arg))
.expect("ProviderArg names are always valid")
} else if let Some(saved) = snapshot.provider.as_deref() {
match ProviderChoice::default_for_provider_name(saved) {
Ok(choice) => choice,
Err(err) => {
eprintln!("yolop: ignoring saved provider `{saved}`: {err}");
ProviderChoice::from_env_or_settings(&snapshot)
}
}
} else {
ProviderChoice::from_env_or_settings(&snapshot)
};
let selected = match (base, cli.model.clone()) {
(ProviderChoice::Anthropic { .. }, Some(m)) => ProviderChoice::Anthropic { model: m },
(
ProviderChoice::OpenAi {
reasoning_effort, ..
},
Some(m),
) => ProviderChoice::OpenAi {
model: m,
reasoning_effort: cli.reasoning_effort.clone().or(reasoning_effort),
},
(ProviderChoice::Google { base_url, .. }, Some(m)) => {
ProviderChoice::Google { model: m, base_url }
}
(ProviderChoice::OpenRouter { base_url, .. }, Some(m)) => {
ProviderChoice::OpenRouter { model: m, base_url }
}
(ProviderChoice::Ollama { base_url, .. }, Some(m)) => {
ProviderChoice::Ollama { model: m, base_url }
}
(other, _) => other,
};
match (selected, cli.reasoning_effort.clone()) {
(
ProviderChoice::OpenAi {
model,
reasoning_effort,
},
effort,
) => ProviderChoice::OpenAi {
model,
reasoning_effort: effort.or(reasoning_effort),
},
(other, _) => other,
}
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("error")),
)
.with_writer(io::stderr)
.init();
let cli = Cli::parse();
let cwd = cli
.cwd
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let settings_path = settings::default_settings_path().unwrap_or_else(|| {
eprintln!(
"yolop: no platform config dir resolvable — settings will not persist across runs"
);
std::path::PathBuf::from("/dev/null/yolop/settings.toml")
});
let settings = Arc::new(SettingsStore::open(settings_path));
let provider = pick_provider(&cli, &settings);
let is_print = cli.print.is_some();
let (gate_tx, approval_rx) = tokio::sync::mpsc::unbounded_channel();
let gate = if cli.ask && !is_print {
ApprovalGate::channel(gate_tx)
} else {
ApprovalGate::auto()
};
let resume_session_id = match cli.session.as_deref() {
Some(raw) => Some(
raw.parse()
.map_err(|e| anyhow::anyhow!("invalid --session id `{raw}`: {e}"))?,
),
None => None,
};
let sessions_dir = match cli.session_dir.clone() {
Some(p) => p,
None => session_log::default_sessions_dir()?,
};
let runtime = runtime::build(
cwd,
provider,
gate,
resume_session_id,
sessions_dir,
settings,
)
.await?;
if let Some(prompt) = cli.print {
return run_print_mode(runtime, prompt).await;
}
run_tui(runtime, approval_rx).await
}
async fn run_tui(
runtime: BuiltRuntime,
approval_rx: tokio::sync::mpsc::UnboundedReceiver<(
approval::ApprovalRequest,
tokio::sync::oneshot::Sender<bool>,
)>,
) -> Result<()> {
let mut raw_mode = RawModeGuard::new()?;
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(COMPOSER_VIEWPORT_HEIGHT),
},
)?;
let mut app = App::new(runtime, approval_rx);
let result = app.run(&mut terminal).await;
let show_resume_hint = app.should_show_resume_hint();
let session_id = app.session_id();
let cleanup_result = terminal.clear().and_then(|_| terminal.show_cursor());
drop(terminal);
raw_mode.disable()?;
cleanup_result?;
if show_resume_hint {
println!();
print_resume_divider();
println!("Resume with yolop --session {session_id}");
println!();
print_centered_ukraine_banner();
}
result
}
struct RawModeGuard {
active: bool,
}
impl RawModeGuard {
fn new() -> Result<Self> {
enable_raw_mode()?;
Ok(Self { active: true })
}
fn disable(&mut self) -> Result<()> {
if self.active {
disable_raw_mode()?;
self.active = false;
}
Ok(())
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if self.active {
let _ = disable_raw_mode();
self.active = false;
}
}
}
fn print_resume_divider() {
let width = crossterm::terminal::size()
.map(|(width, _)| width as usize)
.unwrap_or(80)
.max(1);
println!("\x1b[38;2;45;91;158m{}\x1b[0m", "─".repeat(width));
}
fn print_centered_ukraine_banner() {
let text = ">> Зроблено в Україні <<";
let width = crossterm::terminal::size()
.map(|(width, _)| width as usize)
.unwrap_or(0);
let pad = width.saturating_sub(text.chars().count()) / 2;
println!(
"{}\x1b[38;2;45;91;158m>> Зроблено в \x1b[38;2;126;94;19mУкраїні <<\x1b[0m",
" ".repeat(pad)
);
}
async fn run_print_mode(runtime: BuiltRuntime, prompt: String) -> Result<()> {
let BuiltRuntime {
handles,
startup,
model,
} = runtime;
let color = io::stdout().is_terminal();
println!("{}", paint(color, "90", &format!("› {prompt}")));
println!();
println!(
"{} {}",
paint(color, "90", "workspace"),
startup.workspace_root.display()
);
println!(
"{} {}",
paint(color, "90", "provider"),
paint(color, "96", &model.provider_label())
);
println!(
"{} {}",
paint(color, "90", "tools"),
startup.tool_names.join(", ")
);
println!(
"{} {} (folder: {}; log: {}; {} prior event(s))",
paint(color, "90", "session"),
handles.session_id,
startup.session_dir.display(),
startup.session_log_path.display(),
startup.replayed_events,
);
if !startup.capability_commands.is_empty() {
let names: Vec<String> = startup
.capability_commands
.iter()
.map(|c| format!("/{}", c.name))
.collect();
println!("{} {}", paint(color, "90", "commands"), names.join(", "));
}
println!();
let before_events = handles.runtime.events().await.map(|e| e.len()).unwrap_or(0);
let before_msgs = handles
.runtime
.messages(handles.session_id)
.await
.map(|m| m.len())
.unwrap_or(0);
let input = model.input_message(prompt);
let result = handles.runtime.run_turn(handles.session_id, input).await?;
let events = handles.runtime.events().await.unwrap_or_default();
let messages = handles
.runtime
.messages(handles.session_id)
.await
.unwrap_or_default();
for event in events.iter().skip(before_events) {
if let Some(status) = app::status_for_event(event) {
print_status_line(&status.text, color);
}
for line in app::lines_for_event(event) {
print_transcript_line(&line, color);
}
}
for msg in messages.iter().skip(before_msgs) {
if msg.role == MessageRole::Agent
&& !msg.has_tool_calls()
&& let Some(text) = msg.text()
{
let t = text.trim();
if !t.is_empty() {
println!();
print_transcript_line(
&app::ChatLine {
author: app::Author::Assistant,
text: t.to_string(),
},
color,
);
}
}
}
println!(
"\n{} success={} iterations={} tool_calls={}",
paint(color, if result.success { "92" } else { "91" }, "done"),
result.success,
result.iterations,
result.tool_calls_count
);
if !result.success
&& let Some(err) = result.error
{
eprintln!("turn error: {err}");
std::process::exit(1);
}
Ok(())
}
fn print_transcript_line(line: &app::ChatLine, color: bool) {
match line.author {
app::Author::Assistant => {
println!("{} {}", paint(color, "90", "•"), line.text);
}
app::Author::Tool => {
println!(
"{} {} {}",
paint(color, "92", "•"),
paint(color, "93", line.author.label()),
line.text
);
}
app::Author::ToolDetail => {
println!(" {}", line.text);
}
app::Author::Diff => {
println!(" {}", paint(color, "95", &line.text));
}
app::Author::System | app::Author::User => {
println!(
"{} {} {}",
paint(color, "90", "•"),
paint(color, "90", line.author.label()),
line.text
);
}
}
}
fn print_status_line(text: &str, color: bool) {
println!("{} {}", paint(color, "94", "•"), text);
}
fn paint(enabled: bool, code: &str, text: &str) -> String {
if enabled {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_string()
}
}