use crate::prelude::Message;
use crate::prelude::*;
use beet_core::prelude::*;
use bevy::ecs::spawn::SpawnIter;
#[derive(Debug, Clone)]
pub struct CliAgentPlugin {
pub initial_prompt: Option<String>,
pub initial_prompt_trailing: Vec<String>,
pub input_files: Vec<std::path::PathBuf>,
pub generate_images: bool,
pub config: CliAgentConfig,
}
impl Default for CliAgentPlugin {
fn default() -> Self {
Self {
initial_prompt: None,
initial_prompt_trailing: Vec::new(),
input_files: Vec::new(),
generate_images: false,
config: CliAgentConfig::default(),
}
}
}
impl CliAgentPlugin {
pub fn initial_message(&self) -> bool {
self.initial_prompt.is_some()
|| !self.initial_prompt_trailing.is_empty()
|| !self.input_files.is_empty()
}
}
macro_rules! print_flush {
($($arg:tt)*) => {{
use std::io::{self, Write};
print!($($arg)*);
let _ = io::stdout().flush();
}};
}
#[derive(Debug, Clone, Resource)]
pub struct CliAgentConfig {
oneshot: bool,
out_file: Option<std::path::PathBuf>,
overwrite: bool,
}
impl Default for CliAgentConfig {
fn default() -> Self {
Self {
oneshot: false,
out_file: None,
overwrite: false,
}
}
}
impl CliAgentConfig {
pub fn oneshot(&self) -> bool { self.oneshot }
pub fn next_available_filename(
&self,
extension: &str,
) -> Result<AbsPathBuf> {
let mut filename =
self.out_file.clone().unwrap_or_else(|| "file".into());
filename.set_extension(extension);
let file_stem = filename
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file")
.to_string();
let mut path = AbsPathBuf::new_workspace_rel(filename)?;
if self.overwrite {
path.xok()
} else {
let mut suffix = 0;
while path.exists() {
suffix += 1;
path.set_file_name(format!(
"{}-{}.{}",
file_stem, suffix, extension
));
}
path.xok()
}
}
}
impl Plugin for CliAgentPlugin {
fn build(&self, app: &mut App) {
app.init_plugin::<AgentPlugin>()
.init_plugin::<AsyncPlugin>()
.insert_resource(self.config.clone())
.add_systems(Startup, self.into_system())
.add_observer(text_added)
.add_observer(text_delta)
.add_observer(reasoning_added)
.add_observer(reasoning_ended)
.add_observer(message_ended)
.add_observer(file_inserted)
.add_observer(route_message_requests);
}
}
impl CliAgentPlugin {
pub fn into_system(&self) -> impl 'static + Fn(AsyncCommands) {
let initial_prompt = if let Some(prompt) = &self.initial_prompt {
Some(prompt.clone())
} else if !self.initial_prompt_trailing.is_empty() {
Some(self.initial_prompt_trailing.join(" "))
} else {
None
};
let initial_message = self.initial_message();
let paths = self
.input_files
.iter()
.map(|path| AbsPathBuf::new_workspace_rel(path).unwrap())
.collect::<Vec<_>>();
let generate_images = self.generate_images;
move |mut commands| {
let initial_prompt = initial_prompt.clone();
let paths = paths.clone();
commands.run_local(async move |queue| {
let files =
async_ext::try_join_all(paths.into_iter().map(|path| {
FileContent::new(path.to_string_lossy().to_string())
}))
.await?;
queue.with(move |world| {
#[rustfmt::skip]
world.spawn((
Session::default(),
children![
(
terminal_user(),
OnSpawn::trigger_option(initial_message.xmap_false(||{
MessageRequest
})),
children![
(
Message::default(),
children![
OnSpawn::insert_option(
initial_prompt.map(|prompt| {
println!("User > {}\n", prompt);
session_ext::text(prompt)
})),
Children::spawn(SpawnIter(files.into_iter().map(|file|{
println!("User > {}\n", file);
(file, ContentEnded::default())
})))
]
)]
),
(
{
let mut provider = GeminiAgent::from_env();
if generate_images {
provider =
provider.with_model(GEMINI_2_5_FLASH_IMAGE);
}
provider
},
OnSpawn::trigger_option(initial_message.xmap_true(||{
MessageRequest
})),
)
]));
});
Ok(())
});
}
}
}
fn terminal_user() -> impl Bundle {
(UserRole, OnSpawn::observe(user_message_request))
}
fn text_added(ev: On<Add, TextContent>, cx: SessionParams) -> Result {
let actor = cx.actor(ev.event_target())?;
if actor.role != ActorRole::User {
print_flush!("\n{} > ", actor.role);
}
Ok(())
}
fn text_delta(ev: On<TextDelta>, cx: SessionParams) -> Result {
let actor = cx.actor(ev.event_target())?;
if actor.role != ActorRole::User {
print_flush!("{}", ev.value);
}
Ok(())
}
fn reasoning_added(ev: On<Add, ReasoningContent>, cx: SessionParams) -> Result {
let actor = cx.actor(ev.event().event_target())?;
print_flush!("{} > 🤔", actor.role);
Ok(())
}
fn reasoning_ended(
ev: On<Add, ContentEnded>,
query: Query<(), With<ReasoningContent>>,
) -> Result {
if query.contains(ev.event().event_target()) {
print_flush!(" 💡\n");
}
Ok(())
}
fn file_inserted(
ev: On<Insert, FileContent>,
cx: SessionParams,
config: Res<CliAgentConfig>,
query: Query<&FileContent>,
mut commands: AsyncCommands,
) -> Result {
let file = query.get(ev.event().event_target())?;
let actor = cx.actor(ev.event().event_target())?;
if actor.role != ActorRole::User {
let filename = config.next_available_filename(file.extension())?;
print_flush!("\n{} > file: {}", actor.role, filename);
let file = file.clone();
commands.run_local(async move |_| {
let data = file.data.get().await?;
fs_ext::write_async(filename, data).await?;
Ok(())
});
}
Ok(())
}
fn message_ended(ev: On<Add, MessageComplete>, cx: SessionParams) -> Result {
let actor = cx.actor(ev.event().event_target())?;
if actor.role != ActorRole::User {
print_flush!("\n");
}
Ok(())
}
fn route_message_requests(
ev: On<Add, MessageComplete>,
cx: SessionParams,
config: Res<CliAgentConfig>,
mut commands: Commands,
users: Query<Entity, With<UserRole>>,
agents: Query<Entity, With<AgentRole>>,
) -> Result {
let actor = cx.actor(ev.event().event_target())?;
match actor.role {
ActorRole::User => {
commands.entity(agents.single()?).trigger(MessageRequest);
}
ActorRole::Agent if config.oneshot => {
commands.write_message(AppExit::Success);
}
ActorRole::Agent => {
commands.entity(users.single()?).trigger(MessageRequest);
}
_ => {}
}
Ok(())
}
fn user_message_request(
ev: On<MessageRequest>,
mut commands: AsyncCommands,
cx: SessionParams,
) -> Result {
let actor = cx.actor(ev.event_target())?.entity;
commands.run_local(async move |queue| {
use std::io;
use std::io::Write;
let stdin = io::stdin();
let mut input = String::new();
print_flush!("\nUser > ");
input.clear();
let _ = io::stdout().flush();
let mut spawner = MessageSpawner::spawn(queue.clone(), actor).await?;
match stdin.read_line(&mut input) {
Ok(0) => {
println!("EOF");
}
Ok(_) => {
let line = input.trim_end().to_string();
let id = 0;
spawner
.add(id, (TextContent::new(line), ContentEnded::default()))
.await?
.finish_message()
.await?;
println!();
}
Err(err) => {
eprintln!("Error reading input: {}", err);
}
}
Ok(())
});
Ok(())
}