mod acp;
mod app;
mod capabilities;
mod config_schema;
mod config_service;
mod hooks_config;
mod host_ui;
mod into;
mod mcp_config;
mod runtime;
mod session_log;
mod settings;
#[cfg(test)]
mod test_env;
mod tools;
mod version;
#[cfg(test)]
mod streaming_tests;
#[cfg(test)]
mod mcp_e2e_tests;
#[cfg(test)]
mod agent_scenarios;
use anyhow::Result;
use app::{App, COMPOSER_VIEWPORT_HEIGHT};
use clap::{Args, Parser, Subcommand};
use crossterm::event::{
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crossterm::{execute, queue};
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, Write};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Parser, Debug)]
#[command(
name = "yolop",
version = version::VERSION_DETAILS,
about = "Yolop coding agent — embedded terminal agent built on everruns-runtime"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[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, conflicts_with = "print")]
acp: bool,
#[arg(long)]
session: Option<String>,
#[arg(long)]
session_dir: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Version,
Into(IntoCommand),
}
#[derive(Args, Debug)]
struct IntoCommand {
#[command(subcommand)]
target: IntoTarget,
}
#[derive(Subcommand, Debug)]
enum IntoTarget {
Zed(ZedIntoArgs),
}
#[derive(Args, Debug)]
struct ZedIntoArgs {
#[arg(long = "settings")]
settings_path: Option<PathBuf>,
#[arg(long, default_value = "yolop")]
name: String,
#[arg(long)]
command: Option<PathBuf>,
#[arg(long)]
force: bool,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
enum ProviderArg {
Anthropic,
Openai,
Google,
Openrouter,
Ollama,
Custom,
#[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::Custom => "custom",
ProviderArg::Sim => "llmsim",
}
}
fn pick_provider(cli: &Cli, settings: &SettingsStore) -> ProviderChoice {
let snapshot = settings.snapshot();
let cli_reasoning_effort = runtime::normalize_reasoning_effort(cli.reasoning_effort.clone());
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 base = base.with_saved_model(&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,
reasoning_effort,
..
},
Some(m),
) => ProviderChoice::OpenRouter {
model: m,
base_url,
reasoning_effort: cli_reasoning_effort.clone().or(reasoning_effort),
},
(ProviderChoice::Ollama { base_url, .. }, Some(m)) => {
ProviderChoice::Ollama { model: m, base_url }
}
(
ProviderChoice::Custom {
reasoning_effort, ..
},
Some(m),
) => ProviderChoice::Custom {
model: m,
reasoning_effort: cli_reasoning_effort.clone().or(reasoning_effort),
},
(other, _) => other,
};
match (selected, cli_reasoning_effort) {
(
ProviderChoice::OpenAi {
model,
reasoning_effort,
},
effort,
) => ProviderChoice::OpenAi {
model,
reasoning_effort: effort.or(reasoning_effort),
},
(
ProviderChoice::OpenRouter {
model,
base_url,
reasoning_effort,
},
effort,
) => ProviderChoice::OpenRouter {
model,
base_url,
reasoning_effort: effort.or(reasoning_effort),
},
(
ProviderChoice::Custom {
model,
reasoning_effort,
},
effort,
) => ProviderChoice::Custom {
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();
if let Some(command) = cli.command {
return run_command(command);
}
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 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()?,
};
if cli.acp {
return acp::run_stdio(provider, settings, sessions_dir).await;
}
let interactive = cli.print.is_none();
let runtime = runtime::build_with_options(
cwd,
provider,
resume_session_id,
sessions_dir,
settings,
runtime::BuildOptions {
client_commands: interactive,
..Default::default()
},
)
.await?;
if let Some(prompt) = cli.print {
return run_print_mode(runtime, prompt).await;
}
run_tui(runtime).await
}
fn run_command(command: Commands) -> Result<()> {
match command {
Commands::Version => {
println!("{}", version::VERSION_LINE);
Ok(())
}
Commands::Into(into) => match into.target {
IntoTarget::Zed(args) => {
let command = match args.command {
Some(path) => path,
None => std::env::current_exe().unwrap_or_else(|_| PathBuf::from("yolop")),
};
let result = into::into_zed(into::ZedIntoOptions {
settings_path: args.settings_path,
agent_name: args.name,
command,
force: args.force,
})?;
match result.status {
into::IntoStatus::Unchanged => {
println!(
"yolop: Zed already has `{}` configured at {}",
result.agent_name,
result.settings_path.display()
);
}
into::IntoStatus::Created => {
println!(
"yolop: added `{}` ACP agent to {}",
result.agent_name,
result.settings_path.display()
);
}
into::IntoStatus::Updated => {
println!(
"yolop: updated `{}` ACP agent in {}",
result.agent_name,
result.settings_path.display()
);
}
}
println!("yolop: Zed command: {} --acp", result.command);
Ok(())
}
},
}
}
async fn run_tui(runtime: BuiltRuntime) -> Result<()> {
let mut raw_mode = RawModeGuard::new()?;
let mut keyboard_enhancements = KeyboardEnhancementGuard::new();
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(COMPOSER_VIEWPORT_HEIGHT),
},
)?;
if let Err(err) = anchor_inline_viewport_at_bottom(&mut terminal) {
tracing::warn!("inline viewport anchoring failed, starting unanchored: {err:#}");
}
let mut app = App::new(runtime);
let result = app.run(&mut terminal).await;
let show_resume_hint = app.should_show_resume_hint();
let session_id = app.session_id();
if let Err(err) = terminal.clear() {
tracing::warn!("terminal clear failed: {err:#}");
}
if let Err(err) = terminal.show_cursor() {
tracing::warn!("cursor restore failed: {err:#}");
}
drop(terminal);
keyboard_enhancements.disable();
raw_mode.disable()?;
if show_resume_hint {
println!();
print_resume_divider();
println!("Resume with yolop --session {session_id}");
println!();
print_centered_ukraine_banner();
}
result
}
fn anchor_inline_viewport_at_bottom<B>(terminal: &mut Terminal<B>) -> Result<()>
where
B: ratatui::backend::Backend,
B::Error: std::error::Error + Send + Sync + 'static,
{
let terminal_height = terminal.size()?.height;
let viewport_area = terminal.get_frame().area();
let blank_lines =
blank_lines_after_inline_viewport(viewport_area.y, viewport_area.height, terminal_height);
if blank_lines == 0 {
return Ok(());
}
terminal.insert_before(blank_lines, |_| {})?;
Ok(())
}
fn blank_lines_after_inline_viewport(
viewport_top: u16,
viewport_height: u16,
terminal_height: u16,
) -> u16 {
terminal_height.saturating_sub(viewport_top.saturating_add(viewport_height))
}
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;
}
}
}
struct KeyboardEnhancementGuard {
active: bool,
}
impl KeyboardEnhancementGuard {
fn new() -> Self {
let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
let mut stdout = io::stdout();
let active = execute!(stdout, PushKeyboardEnhancementFlags(flags)).is_ok();
Self { active }
}
fn disable(&mut self) {
if self.active {
let mut stdout = io::stdout();
let _ = queue!(stdout, PopKeyboardEnhancementFlags);
let _ = stdout.flush();
self.active = false;
}
}
}
impl Drop for KeyboardEnhancementGuard {
fn drop(&mut self) {
self.disable();
}
}
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(", "));
}
if startup.hook_configured {
println!(
"{} {}",
paint(color, "90", "hooks"),
startup.hook_summary()
);
}
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::Narration => {
println!(
"{} {} {}",
paint(color, "90", "•"),
paint(color, "90", line.author.label()),
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cli_with_reasoning_effort(reasoning_effort: Option<&str>) -> Cli {
Cli {
command: None,
cwd: None,
provider: Some(ProviderArg::Openrouter),
model: Some("nvidia/nemotron-3-super-120b-a12b".to_string()),
reasoning_effort: reasoning_effort.map(str::to_string),
print: None,
acp: false,
session: None,
session_dir: None,
}
}
#[test]
fn pick_provider_normalizes_cli_reasoning_effort() {
let tmp = tempfile::tempdir().expect("settings tempdir");
let settings = SettingsStore::open(tmp.path().join("settings.toml"));
let provider = pick_provider(&cli_with_reasoning_effort(Some(" HIGH ")), &settings);
assert_eq!(
provider.label(),
"openrouter/nvidia/nemotron-3-super-120b-a12b high"
);
}
#[test]
fn pick_provider_ignores_blank_cli_reasoning_effort() {
let tmp = tempfile::tempdir().expect("settings tempdir");
let settings = SettingsStore::open(tmp.path().join("settings.toml"));
let provider = pick_provider(&cli_with_reasoning_effort(Some(" ")), &settings);
assert_eq!(
provider.label(),
"openrouter/nvidia/nemotron-3-super-120b-a12b"
);
}
#[test]
fn pick_provider_applies_saved_model_for_saved_provider() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("EVERRUNS_CLI_MODEL");
}
let tmp = tempfile::tempdir().expect("settings tempdir");
let path = tmp.path().join("settings.toml");
std::fs::write(
&path,
"provider = \"openai\"\n\n[models]\nopenai = \"gpt-5.4 high\"\n",
)
.expect("write settings");
let settings = SettingsStore::open(path);
let cli = Cli {
command: None,
cwd: None,
provider: None,
model: None,
reasoning_effort: None,
print: None,
acp: false,
session: None,
session_dir: None,
};
let provider = pick_provider(&cli, &settings);
assert_eq!(provider.label(), "openai/gpt-5.4 high");
}
#[test]
fn inline_viewport_anchor_fills_space_above_bottom_target() {
assert_eq!(blank_lines_after_inline_viewport(4, 18, 60), 38);
}
#[test]
fn inline_viewport_anchor_does_not_scroll_when_already_low_enough() {
assert_eq!(blank_lines_after_inline_viewport(42, 18, 60), 0);
assert_eq!(blank_lines_after_inline_viewport(50, 18, 60), 0);
}
#[test]
fn inline_viewport_anchor_handles_small_terminals() {
assert_eq!(blank_lines_after_inline_viewport(0, 18, 10), 0);
}
}