use std::cell::RefCell;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::rc::{Rc, Weak};
use std::sync::Arc;
use std::time::Duration;
use crate::agent::extension::ToolRenderer;
use yoagent::types::AgentTool;
use crate::agent::AgentSession;
use crate::agent::extension::{CommandResult, Extension};
use crate::agent::footer_data_provider::FooterDataProvider;
use crate::agent::session::SessionEntry;
use crate::auth;
use crate::builtin::export;
use crate::provider;
use crate::provider::ProviderRegistry;
use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
use crate::agent::ui::components::EditorComponent;
use crate::agent::ui::components::FooterComponent;
use crate::agent::ui::components::InfoMessageComponent;
use crate::agent::ui::footer::Footer;
use crate::agent::ui::theme::RabTheme;
use crate::agent::ui::working::WorkingIndicator;
use crate::builtin::commands::SessionInfoInternal;
use crate::tui::Component;
use crate::tui::TUI;
use crate::tui::focusable::Focusable;
pub type PendingLabelChanges = Rc<RefCell<Vec<(String, Option<String>)>>>;
#[derive(Debug, Clone)]
pub enum OverlayResult {
ModelSelected(String),
ScopedModelsAccepted(Option<Vec<String>>),
ScopedModelsCancelled,
LoginProviderSelected(String),
LoginApiKeyProvided { provider: String, key: String },
LoginAuthTypeSelected(AuthType),
LogoutProviderSelected(String),
ImportConfirmed(String),
ImportCancelled,
TreeNavigateTo(String),
TreeCancelled,
TreeSummarizeChoice {
entry_id: String,
summarize: bool,
custom_instructions: Option<String>,
},
TreeReopen(String),
}
use crate::agent::ui::components::oauth_selector::AuthType;
use crate::agent::ui::theme::ThemeKey;
use crate::tui::components::Spacer;
use crate::tui::components::Text;
use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
use crossterm::event::KeyEvent;
use tokio::sync::mpsc;
const ALL_THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
fn available_thinking_levels(app: &App) -> Vec<&'static str> {
let thinking_map: Option<std::collections::HashMap<String, Option<serde_json::Value>>> = app
.registry
.resolve(&app.model, Some(&app.current_provider))
.ok()
.and_then(|r| {
r.model_config
.headers
.get("_rab_thinking_map")
.and_then(|json| serde_json::from_str(json).ok())
});
match thinking_map {
Some(map) => ALL_THINKING_LEVELS
.iter()
.filter(|level| {
if **level == "off" {
return true; }
!matches!(map.get(**level), Some(None))
})
.copied()
.collect(),
None => ALL_THINKING_LEVELS.to_vec(),
}
}
pub struct AppConfig {
pub model: String,
pub provider: String,
pub system_prompt: String,
pub extensions: Vec<Box<dyn Extension>>,
pub cwd: PathBuf,
pub thinking_level: Option<String>,
pub available_models: Vec<String>,
pub hide_thinking: bool,
pub collapse_tool_output: bool,
pub interactive: bool,
pub settings: crate::agent::settings::Settings,
pub context_files: Vec<String>,
pub skills: Vec<yoagent::skills::Skill>,
pub skill_dirs: Vec<PathBuf>,
pub agent_dir: PathBuf,
pub prompt_templates: Vec<crate::agent::prompt_templates::PromptTemplate>,
pub prompt_template_dirs: Vec<PathBuf>,
pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
pub api_key: String,
pub registry: Arc<ProviderRegistry>,
}
pub struct App {
cwd: PathBuf,
model: String,
current_provider: String,
thinking_level: Option<String>,
system_prompt: String,
theme: RabTheme,
commands: Vec<(String, String)>,
available_models: Vec<String>,
registry: Arc<ProviderRegistry>,
pub chat_container: std::rc::Rc<std::cell::RefCell<crate::tui::Container>>,
pub status_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
pub working_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
editor: Rc<RefCell<ChatEditor>>,
event_tx: mpsc::UnboundedSender<yoagent::types::AgentEvent>,
event_rx: mpsc::UnboundedReceiver<yoagent::types::AgentEvent>,
is_streaming: bool,
pending_submit: Option<String>,
pending_compact: Option<Option<String>>,
pending_auto_compact: bool,
agent: Option<yoagent::agent::Agent>,
forward_handle: Option<tokio::task::JoinHandle<()>>,
oauth_join_handle: Option<tokio::task::JoinHandle<()>>,
pending_oauth_provider: Option<String>,
hide_thinking: bool,
collapse_tool_output: bool,
tools_expanded: bool,
scroll_offset: usize,
last_clear_time: std::time::Instant,
should_quit: bool,
pending_tool_executions: usize,
bash_abort_handle: Option<tokio::task::AbortHandle>,
session: Option<AgentSession>,
footer: Rc<RefCell<Footer>>,
footer_provider: Rc<RefCell<FooterDataProvider>>,
pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
tool_call_start_times: HashMap<String, std::time::Instant>,
invalidate_rxs: Vec<tokio::sync::mpsc::UnboundedReceiver<()>>,
streaming_component:
Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
working: WorkingIndicator,
status_text: Option<String>,
pending_command_result: Option<CommandResult>,
overlay_result_signal: Rc<RefCell<Option<OverlayResult>>>,
pending_scoped_ids: Rc<RefCell<Option<Vec<String>>>>,
extensions: Arc<Vec<Box<dyn Extension>>>,
skills: Vec<yoagent::skills::Skill>,
skill_dirs: Vec<PathBuf>,
agent_dir: PathBuf,
context_files: Vec<String>,
prompt_template_dirs: Vec<PathBuf>,
prompt_templates: Vec<crate::agent::prompt_templates::PromptTemplate>,
api_key: String,
session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
auto_compact: bool,
settings: crate::agent::settings::Settings,
header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
scoped_model_ids: Option<Vec<String>>,
session_picker: Option<crate::agent::ui::components::SessionPicker>,
last_status_len: Option<usize>,
pending_label_changes: PendingLabelChanges,
}
impl App {
fn new(config: AppConfig, session: AgentSession) -> Self {
let mut agent_session = session;
let model_config = config
.registry
.resolve(&config.model, Some(&config.provider))
.ok()
.map(|r| r.model_config.clone())
.unwrap_or_else(|| {
let mut mc = crate::agent::base_model_config(&config.model);
mc.context_window =
crate::agent::compaction::get_model_context_window(&config.model) as u32;
mc
});
agent_session.set_compaction_config(
config.api_key.clone(),
&config.model,
crate::agent::compaction::get_model_context_window(&config.model),
Some(model_config),
);
agent_session.set_registry(config.registry.clone());
agent_session.set_auto_compact(config.settings.auto_compact.unwrap_or(true));
let (tx, rx) = mpsc::unbounded_channel();
use crate::agent::ui::theme::current_theme;
let theme = current_theme().clone();
let mut editor = ChatEditor::new(&theme, config.cwd.clone());
use crate::tui::autocomplete::AutocompleteItem as AutoAutocompleteItem;
use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
let mut auto_commands: Vec<AutoSlashCommand> = config
.extensions
.iter()
.flat_map(|e| e.commands())
.map(|cmd| {
let handler = cmd.handler;
AutoSlashCommand {
name: cmd.name,
description: Some(cmd.description),
argument_hint: None,
argument_completions: None,
get_argument_completions: Some(std::sync::Arc::new(
move |prefix: &str| -> Vec<AutoAutocompleteItem> {
handler
.argument_completions(prefix)
.into_iter()
.map(|item| AutoAutocompleteItem {
value: item.value,
label: item.label,
description: item.description,
})
.collect()
},
)),
}
})
.collect();
for skill in &config.skills {
let cmd_name = format!("skill:{}", skill.name);
auto_commands.push(AutoSlashCommand {
name: cmd_name,
description: Some(skill.description.clone()),
argument_hint: None,
argument_completions: None,
get_argument_completions: None,
});
}
for template in &config.prompt_templates {
auto_commands.push(AutoSlashCommand {
name: template.name.clone(),
description: Some(template.description.clone()),
argument_hint: template.argument_hint.clone(),
argument_completions: None,
get_argument_completions: None,
});
}
editor.set_slash_commands(auto_commands);
let mut commands: Vec<(String, String)> = config
.extensions
.iter()
.flat_map(|e| e.commands())
.map(|c| (c.name, c.description))
.collect();
for skill in &config.skills {
commands.push((format!("skill:{}", skill.name), skill.description.clone()));
}
for template in &config.prompt_templates {
commands.push((template.name.clone(), template.description.clone()));
}
let editor = Rc::new(RefCell::new(editor));
let footer_provider = Rc::new(RefCell::new(FooterDataProvider::new(config.cwd.clone())));
let mut footer = Footer::new(
config.cwd.to_string_lossy().to_string(),
footer_provider.clone(),
);
footer.set_context_window(crate::agent::compaction::get_model_context_window(
&config.model,
));
footer_provider
.borrow_mut()
.set_available_provider_count(config.registry.count_providers());
{
let has_model_entry = !agent_session
.session()
.find_entries("model_change")
.is_empty();
if !has_model_entry {
agent_session.on_model_change(&config.provider, &config.model);
}
let has_thinking_entry = !agent_session
.session()
.find_entries("thinking_level_change")
.is_empty();
if !has_thinking_entry && let Some(ref level) = config.thinking_level {
agent_session.on_thinking_level_change(level);
}
}
let footer = Rc::new(RefCell::new(footer));
let context = agent_session.session().build_session_context();
let history_messages = context.messages.clone();
let cwd_string = config.cwd.to_string_lossy().to_string();
let context_file_paths: Vec<String> = config
.context_files
.iter()
.map(|s| {
if let Some(rel) = s.strip_prefix(&cwd_string) {
if rel.is_empty() {
s.clone()
} else {
format!("./{}", rel.trim_start_matches('/'))
}
} else if let Some(home) =
std::env::var_os("HOME").and_then(|h| h.into_string().ok())
&& let Some(rel) = s.strip_prefix(&home)
{
if rel.is_empty() {
s.clone()
} else {
format!("~/{}", rel.trim_start_matches('/'))
}
} else {
s.clone()
}
})
.collect();
let skill_names: Vec<String> = config.skills.iter().map(|s| s.name.clone()).collect();
let template_names: Vec<String> = config
.prompt_templates
.iter()
.map(|t| t.name.clone())
.collect();
let extension_names: Vec<String> = config
.extensions
.iter()
.map(|e| e.name().to_string())
.collect();
let theme_names: Vec<String> = crate::agent::ui::theme::get_available_themes()
.into_iter()
.filter(|n| n != "dark" && n != "light")
.collect();
let chat_container =
std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
{
let mut chat = chat_container.borrow_mut();
rebuild_chat_from_messages(
&mut chat,
&history_messages,
&cwd_string,
config.hide_thinking,
config.collapse_tool_output,
&config.extensions,
);
}
let verbose = config.settings.verbose;
let mut result = Self {
cwd: config.cwd,
model: config.model,
current_provider: config.provider,
thinking_level: config.thinking_level,
system_prompt: config.system_prompt,
theme,
commands,
available_models: config.available_models,
registry: config.registry.clone(),
chat_container,
pending_tools: HashMap::new(),
tool_call_start_times: HashMap::new(),
invalidate_rxs: Vec::new(),
streaming_component: None,
status_section: std::rc::Rc::new(std::cell::RefCell::new(
crate::tui::components::DynamicLines::new(),
)),
working_section: std::rc::Rc::new(std::cell::RefCell::new(
crate::tui::components::DynamicLines::new(),
)),
editor,
event_tx: tx,
event_rx: rx,
is_streaming: false,
pending_submit: None,
pending_compact: None,
pending_auto_compact: false,
agent: None,
forward_handle: None,
oauth_join_handle: None,
pending_oauth_provider: None,
pending_command_result: None,
overlay_result_signal: Rc::new(RefCell::new(None)),
pending_scoped_ids: Rc::new(RefCell::new(None)),
hide_thinking: config.hide_thinking,
collapse_tool_output: config.collapse_tool_output,
tools_expanded: !config.collapse_tool_output,
scroll_offset: 0,
last_clear_time: std::time::Instant::now(),
should_quit: false,
pending_tool_executions: 0,
bash_abort_handle: None,
session: Some(agent_session),
footer,
footer_provider,
working: WorkingIndicator::new(),
extensions: Arc::new(config.extensions),
skills: config.skills,
skill_dirs: config.skill_dirs,
agent_dir: config.agent_dir,
prompt_template_dirs: config.prompt_template_dirs,
prompt_templates: config.prompt_templates,
session_info: config.session_info,
api_key: config.api_key,
scoped_model_ids: config.settings.enabled_models.clone(),
settings: config.settings,
auto_compact: true,
status_text: None,
context_files: context_file_paths.clone(),
header: Rc::new(RefCell::new(
crate::agent::ui::components::HeaderComponent::new_with_expanded(
!config.collapse_tool_output || verbose,
),
)),
session_picker: None,
last_status_len: None,
pending_label_changes: Rc::new(RefCell::new(Vec::new())),
};
{
let mut hdr = result.header.borrow_mut();
hdr.set_resource_data(
context_file_paths,
skill_names,
template_names,
extension_names,
theme_names,
);
}
result.update_session_info();
if let Some(ref mut s) = result.session {
result.footer.borrow_mut().refresh_from_session(s.session());
}
result
}
fn update_session_info(&self) {
if let Some(ref session) = self.session
&& let Some(ref info) = self.session_info
{
let si = crate::builtin::commands::compute_session_info(session.session());
if let Ok(mut guard) = info.lock() {
*guard = Some(si);
}
}
}
fn refresh_git_branch(&self) {
self.footer_provider.borrow_mut().refresh_git_branch();
}
fn clear_session_state(&mut self) {
self.chat_container.borrow_mut().clear();
self.streaming_component = None;
self.pending_tools.clear();
self.tool_call_start_times.clear();
self.pending_submit = None;
}
fn rebuild_from_session_context(&mut self) {
if let Some(ref agent_session) = self.session {
let context = agent_session.session().build_session_context();
{
let mut chat = self.chat_container.borrow_mut();
rebuild_chat_from_messages(
&mut chat,
&context.messages,
&self.cwd.to_string_lossy(),
self.hide_thinking,
self.collapse_tool_output,
&self.extensions,
);
}
if let Some(ref mut agent) = self.agent {
agent.replace_messages(context.messages);
}
}
}
fn record_model_change(&mut self, model: &str) {
if let Some(ref mut agent_session) = self.session {
agent_session.on_model_change(&self.current_provider, model);
}
if let Some(ref session) = self.session {
self.footer
.borrow_mut()
.refresh_from_session(session.session());
}
}
fn refresh_registry(&mut self) {
match provider::ProviderRegistry::load(&provider::get_agent_dir()) {
Ok(new_reg) => self.registry = Arc::new(new_reg),
Err(e) => {
self.status_text = Some(format!("Failed to refresh registry: {}", e));
}
}
}
fn propagate_hide_thinking(&mut self) {
let hide = self.hide_thinking;
{
let mut chat = self.chat_container.borrow_mut();
for child in chat.children_mut().iter_mut() {
child.set_hide_thinking(hide);
}
}
if let Some(weak) = self.streaming_component.as_ref().and_then(|w| w.upgrade()) {
weak.borrow_mut().set_hide_thinking(hide);
}
}
fn switch_to_session(&mut self, new_session: AgentSession) {
let ctx = new_session.session().build_session_context();
self.clear_session_state();
rebuild_chat_from_messages(
&mut self.chat_container.borrow_mut(),
&ctx.messages,
&self.cwd.to_string_lossy(),
self.hide_thinking,
self.collapse_tool_output,
&self.extensions,
);
self.footer
.borrow_mut()
.refresh_from_session(new_session.session());
self.session = Some(new_session);
self.agent = None;
self.update_session_info();
}
}
pub async fn run(config: AppConfig, session: AgentSession) -> anyhow::Result<()> {
crate::agent::ui::theme::init_theme(Some("dark"), false);
let mut term = ProcessTerminal::new();
let mut stdout = std::io::stdout();
term.start(&mut stdout)?;
term.hide_cursor(&mut stdout)?;
term.set_color_scheme_notifications(&mut stdout, true)?;
crate::tui::terminal::start_stdin_reader();
let mut tui = TUI::new();
tui.set_clear_on_shrink(false);
let mut app = App::new(config, session);
app.editor.borrow_mut().editor.set_focused(true);
tui.root.add_child(std::boxed::Box::new(Spacer::new(1)));
tui.root.add_child(std::boxed::Box::new(
crate::tui::components::RcRefCellComponent(
app.header.clone() as Rc<RefCell<dyn Component>>,
),
));
tui.root.add_child(std::boxed::Box::new(Spacer::new(1)));
tui.root.add_child(std::boxed::Box::new(
crate::tui::components::RcRefCellComponent(app.chat_container.clone()
as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
));
tui.root.add_child(std::boxed::Box::new(
crate::tui::components::RcRefCellComponent(app.status_section.clone()
as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
));
tui.root.add_child(std::boxed::Box::new(
crate::tui::components::RcRefCellComponent(app.working_section.clone()
as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
));
tui.root
.add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
tui.root
.add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
app.editor.borrow_mut().update_border_color(
app.thinking_level.as_deref(),
&app.theme as &dyn crate::tui::Theme,
);
let mut cols: u16 = 80;
let mut rows: u16 = 24;
let mut dirty = true;
loop {
let mut had_event = false;
while let Ok(event) = app.event_rx.try_recv() {
handle_agent_event(&mut app, event);
had_event = true;
}
if had_event {
dirty = true;
}
loop {
match terminal::try_recv_terminal_event() {
Some(terminal::TerminalEvent::Key(key)) => {
if !tui.route_input(&key) {
handle_input(&mut app, &mut tui, &mut term, &key);
}
}
Some(terminal::TerminalEvent::Paste(content)) => {
if !tui.route_paste(&content) {
app.editor.borrow_mut().editor.handle_paste(&content);
}
}
Some(terminal::TerminalEvent::Resize(w, h)) => {
app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
tui.set_dimensions(w as usize, h as usize);
}
None => break,
}
dirty = true;
}
if let Some(ids) = app.pending_scoped_ids.borrow_mut().take() {
let auth_count = app.registry.list_authenticated_model_ids().len();
if ids.is_empty() || ids.len() >= auth_count {
app.scoped_model_ids = None;
} else {
app.scoped_model_ids = Some(ids);
}
dirty = true;
}
if tui.has_overlays() {
let changes = app
.pending_label_changes
.borrow_mut()
.drain(..)
.collect::<Vec<_>>();
for (entry_id, label) in changes {
if let Some(ref mut session) = app.session {
let _ = session
.session_mut()
.append_label_change(&entry_id, label.as_deref());
}
}
}
if tui.has_overlays() {
let result = app.overlay_result_signal.borrow_mut().take();
if let Some(result) = result {
tui.pop_overlay();
match result {
OverlayResult::ModelSelected(full_id) => {
if !full_id.is_empty() {
let (provider, model_id) = full_id
.split_once('/')
.map(|(p, m)| (p.to_string(), m.to_string()))
.unwrap_or_else(|| (String::new(), full_id.clone()));
app.current_provider = provider;
app.model = model_id.clone();
app.record_model_change(&model_id);
app.status_text = Some(format!("Model: {}", full_id));
}
}
OverlayResult::ScopedModelsAccepted(ids) => {
match ids {
Some(ids)
if !ids.is_empty()
&& ids.len()
< app.registry.list_authenticated_model_ids().len() =>
{
app.scoped_model_ids = Some(ids.clone());
app.settings.set_enabled_models(Some(ids));
if let Err(e) = app.settings.save() {
app.status_text =
Some(format!("Failed to save model scope: {}", e));
} else {
app.status_text = Some("Model scope saved to settings".into());
}
}
_ => {
app.scoped_model_ids = None;
app.settings.set_enabled_models(None);
if let Err(e) = app.settings.save() {
app.status_text =
Some(format!("Failed to save model scope: {}", e));
} else if ids.is_some() {
app.status_text = Some("Model scope saved to settings".into());
}
}
}
}
OverlayResult::ScopedModelsCancelled => {
}
OverlayResult::LoginAuthTypeSelected(auth_type) => {
show_login_provider_selector(&mut app, &mut tui, Some(auth_type));
}
OverlayResult::LoginProviderSelected(provider_id) => {
if crate::provider::oauth::get(&provider_id).is_some() {
show_oauth_login_dialog(&mut app, &mut tui, &provider_id);
} else {
show_api_key_login_dialog(&mut app, &mut tui, &provider_id);
}
}
OverlayResult::LoginApiKeyProvided { provider, key } => {
if let Some(err_msg) = key.strip_prefix("OAUTH_LOGIN_FAILED:") {
app.status_text = Some(format!("OAuth login failed: {}", err_msg));
} else {
match auth::login(&provider, &key) {
Ok(_) => {
app.status_text = Some(format!("Logged in to {}", provider));
app.refresh_registry();
complete_login(&mut app, &provider, AuthType::ApiKey);
}
Err(e) => {
app.status_text = Some(format!("Login failed: {}", e));
}
}
}
}
OverlayResult::LogoutProviderSelected(provider_id) => {
match auth::logout(Some(&provider_id)) {
Ok(true) => {
app.status_text = Some(format!("Logged out from {}", provider_id));
app.refresh_registry();
}
Ok(false) => {
app.status_text =
Some(format!("No credentials for {}", provider_id));
}
Err(e) => {
app.status_text = Some(format!("Logout failed: {}", e));
}
}
}
OverlayResult::ImportConfirmed(path) => {
let result = (|| -> Result<PathBuf, String> {
let resolved = crate::builtin::resolve_path(&path, &app.cwd);
if !resolved.exists() {
return Err(format!("File not found: {}", resolved.display()));
}
let session_dir = app
.session
.as_ref()
.map(|s| s.session_manager().session_dir().to_path_buf())
.unwrap_or_else(|| {
crate::agent::session::get_default_session_dir(&app.cwd)
});
std::fs::create_dir_all(&session_dir)
.map_err(|e| format!("Failed to create session dir: {}", e))?;
let dest = session_dir.join(
resolved
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("session.jsonl")),
);
if dest != resolved {
std::fs::copy(&resolved, &dest)
.map_err(|e| format!("Failed to copy session file: {}", e))?;
}
let agent_session = crate::agent::AgentSession::open(
&dest,
Some(&session_dir),
Some(&app.cwd),
);
app.working.stop();
app.status_text = None;
app.switch_to_session(agent_session);
Ok(dest)
})();
match result {
Ok(path) => {
chat_info(
&mut app,
format!(
"✓ Imported and switched to session: {}",
crate::builtin::shorten_path(&path.to_string_lossy())
),
);
}
Err(msg) => {
chat_info(&mut app, format!("✗ {}", msg));
}
}
}
OverlayResult::ImportCancelled => {
chat_info(&mut app, "Import cancelled.");
}
OverlayResult::TreeNavigateTo(entry_id) => {
let current_leaf =
app.session.as_ref().and_then(|s| s.session().get_leaf_id());
if current_leaf.as_deref() == Some(&entry_id) {
app.status_text = Some("Already at this point".to_string());
} else {
show_summarization_prompt(&mut app, &mut tui, &entry_id);
}
}
OverlayResult::TreeCancelled => {
}
OverlayResult::TreeSummarizeChoice {
entry_id,
summarize,
custom_instructions,
} => {
if summarize {
if let Some(ref mut session) = app.session {
match session
.set_branch(&entry_id, custom_instructions.as_deref())
.await
{
Ok(_) => {
app.status_text =
Some("Navigated to selected point".to_string());
app.rebuild_from_session_context();
}
Err(e) => {
app.status_text = Some(format!("Navigation error: {}", e));
}
}
}
} else {
if let Some(ref mut session) = app.session {
match session.session_mut().set_leaf_id(Some(&entry_id)) {
Ok(_) => {
app.status_text = Some(
"Navigated to selected point (no summary)".to_string(),
);
app.rebuild_from_session_context();
}
Err(e) => {
app.status_text = Some(format!("Navigation error: {}", e));
}
}
}
}
}
OverlayResult::TreeReopen(entry_id) => {
if let Some(ref session) = app.session {
let tree = session.session_manager().get_tree();
let leaf_id = session.session().get_leaf_id();
let signal_select = app.overlay_result_signal.clone();
let signal_cancel = app.overlay_result_signal.clone();
let label_signal = app.pending_label_changes.clone();
let mut tree_selector = crate::agent::ui::components::TreeSelector::new(
tree,
leaf_id,
rows as usize,
None,
);
if !entry_id.is_empty() {
tree_selector.set_initial_selection(&entry_id);
}
tree_selector.on_select = Some(Box::new(move |eid| {
*signal_select.borrow_mut() =
Some(OverlayResult::TreeNavigateTo(eid));
}));
tree_selector.on_cancel = Some(Box::new(move || {
*signal_cancel.borrow_mut() = Some(OverlayResult::TreeCancelled);
}));
tree_selector.on_label_change = Some(Box::new(move |eid, label| {
label_signal.borrow_mut().push((eid, label));
}));
tui.show_top_overlay(Box::new(tree_selector));
}
}
}
}
dirty = true;
}
while let Ok(event) = app.event_rx.try_recv() {
handle_agent_event(&mut app, event);
dirty = true;
}
if app.forward_handle.as_ref().is_some_and(|h| h.is_finished()) {
app.forward_handle.take();
if let Some(ref mut agent) = app.agent {
agent.finish().await;
}
}
if app
.oauth_join_handle
.as_ref()
.is_some_and(|h| h.is_finished())
{
app.oauth_join_handle.take();
let oauth_provider = app.pending_oauth_provider.take();
if let Some(ref provider_id) = oauth_provider
&& let Ok(Some(auth::AuthCredential::Oauth { .. })) =
auth::read_credential(provider_id)
{
let provider_name = app
.registry
.list_providers()
.into_iter()
.find(|(id, _)| id == provider_id)
.map(|(_, name)| name)
.unwrap_or_else(|| provider_id.clone());
let msg = format!("✓ Logged in to {} via OAuth", provider_name);
app.status_text = Some(msg.clone());
chat_info(&mut app, &msg);
app.refresh_registry();
complete_login(
&mut app,
provider_id,
crate::agent::ui::components::oauth_selector::AuthType::OAuth,
);
} else if oauth_provider.is_some() {
let err_msg = app.status_text.clone().unwrap_or_default();
if !err_msg.is_empty() {
chat_info(&mut app, &err_msg);
}
}
}
if !app.is_streaming
&& let Some(text) = app.pending_submit.take()
{
start_agent_loop(&mut app, text).await;
dirty = true;
}
if let Some(custom_instructions) = app.pending_compact.take() {
handle_compact_command(&mut app, custom_instructions).await;
dirty = true;
}
if app.pending_auto_compact {
app.pending_auto_compact = false;
handle_auto_compact(&mut app).await;
dirty = true;
}
if let Some(result) = app.pending_command_result.take() {
match result {
CommandResult::ShowHelp => {
show_help_overlay(&mut app, &mut tui);
}
CommandResult::OpenSessionSelector => {
let mut picker = crate::agent::ui::components::SessionPicker::new();
let repo = crate::agent::DefaultSessionRepo::new();
picker.load_sessions(&repo);
app.session_picker = Some(picker);
app.status_text = None;
}
CommandResult::OpenModelSelector => {
open_model_selector(&mut app, &mut tui);
}
CommandResult::OpenSettings => {
chat_info(&mut app, "Settings menu - not yet implemented.");
}
CommandResult::ScopedModels => {
open_scoped_models_selector(&mut app, &mut tui);
}
CommandResult::Login {
ref provider,
ref api_key,
} => {
if let (Some(provider), Some(key)) = (provider, api_key) {
handle_login(&mut app, provider, Some(key));
} else if let Some(provider) = provider {
show_api_key_login_dialog(&mut app, &mut tui, provider);
} else {
show_auth_type_or_provider_selector(&mut app, &mut tui);
}
}
CommandResult::Logout { provider } => match provider {
Some(p) => handle_logout(&mut app, Some(&p)),
None => show_logout_provider_selector(&mut app, &mut tui),
},
CommandResult::ImportSession { path } => {
let resolved = crate::builtin::resolve_path(&path, &app.cwd);
if !resolved.exists() {
chat_info(
&mut app,
format!("✗ File not found: {}", resolved.display()),
);
} else {
let display_path = resolved.display().to_string();
let signal = app.overlay_result_signal.clone();
let path_for_confirm = path.clone();
let mut confirm =
Box::new(crate::agent::ui::components::ConfirmOverlay::new(
"Import Session",
format!("Replace current session with {}?", display_path),
));
confirm.on_confirm({
let signal = signal.clone();
move || {
*signal.borrow_mut() =
Some(OverlayResult::ImportConfirmed(path_for_confirm));
}
});
confirm.on_cancel({
let signal = signal.clone();
move || {
*signal.borrow_mut() = Some(OverlayResult::ImportCancelled);
}
});
tui.show_overlay(confirm, Default::default());
}
}
CommandResult::SessionTree => {
if let Some(ref session) = app.session {
let tree = session.session_manager().get_tree();
let leaf_id = session.session().get_leaf_id();
let signal_select = app.overlay_result_signal.clone();
let signal_cancel = app.overlay_result_signal.clone();
let label_signal = app.pending_label_changes.clone();
let mut tree_selector = crate::agent::ui::components::TreeSelector::new(
tree,
leaf_id,
rows as usize,
None,
);
tree_selector.on_select = Some(Box::new(move |entry_id| {
*signal_select.borrow_mut() =
Some(OverlayResult::TreeNavigateTo(entry_id));
}));
tree_selector.on_cancel = Some(Box::new(move || {
*signal_cancel.borrow_mut() = Some(OverlayResult::TreeCancelled);
}));
tree_selector.on_label_change = Some(Box::new(move |entry_id, label| {
label_signal.borrow_mut().push((entry_id, label));
}));
use crate::tui::focusable::Focusable;
tree_selector.set_focused(true);
tui.show_top_overlay(Box::new(tree_selector));
} else {
chat_info(&mut app, "No active session.");
}
}
_ => {}
}
dirty = true;
}
app.invalidate_rxs.retain_mut(|rx| {
if rx.try_recv().is_ok() {
dirty = true;
true
} else {
!rx.is_closed()
}
});
if dirty && let Ok((w, h)) = term.size() {
app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
cols = w;
rows = h;
}
if app.working.tick() {
dirty = true;
}
let mut tools_to_remove: Vec<String> = Vec::new();
for (id, weak) in app.pending_tools.iter() {
if let Some(comp) = weak.upgrade() {
if comp.borrow_mut().tick_timer() {
dirty = true;
}
} else {
tools_to_remove.push(id.clone());
}
}
for id in tools_to_remove {
app.pending_tools.remove(&id);
}
if dirty {
compose_ui(&mut app, cols as usize);
tui.set_dimensions(cols as usize, rows as usize);
tui.render(cols as usize, rows as usize, &mut stdout)?;
dirty = false;
}
tokio::time::sleep(if dirty || app.is_streaming || app.working.should_show() {
Duration::from_millis(16)
} else {
Duration::from_millis(50)
})
.await;
app.status_text = None;
if app.should_quit {
if let Some(handle) = app.oauth_join_handle.take() {
handle.abort();
}
break;
}
}
tui.finalize(&mut stdout)?;
term.set_color_scheme_notifications(&mut stdout, false)?;
term.show_cursor(&mut stdout)?;
term.stop(&mut stdout)?;
Ok(())
}
fn compose_ui(app: &mut App, width: usize) {
if let Some(ref picker) = app.session_picker {
let (_lines, _cursor_y) = picker.render(width, &app.theme as &dyn crate::tui::Theme);
app.chat_container.borrow_mut().clear();
app.status_section.borrow_mut().set_lines(vec![]);
app.working_section.borrow_mut().set_lines(vec![]);
return;
}
let mut status_lines = Vec::new();
if let Some(ref status) = app.status_text {
let line = app.theme.fg_key(ThemeKey::Dim, &format!(" {}", status));
status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
}
if app.is_streaming {
if let Some(ref msg) = app.pending_submit {
let preview = if msg.len() > 60 {
format!("{}…", &msg[..60])
} else {
msg.clone()
};
let line = app
.theme
.fg_key(ThemeKey::Dim, &format!(" 📝 queued: {}", preview));
status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
}
}
app.status_section.borrow_mut().set_lines(status_lines);
let mut working_lines = Vec::new();
let wl = app.working.render(width);
working_lines.extend(wl);
app.working_section.borrow_mut().set_lines(working_lines);
}
fn user_agent_message(text: &str) -> yoagent::types::AgentMessage {
yoagent::types::AgentMessage::Llm(yoagent::types::Message::User {
content: vec![yoagent::types::Content::Text {
text: text.to_string(),
}],
timestamp: yoagent::types::now_ms(),
})
}
fn handle_input(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal, key: &KeyEvent) {
if app.session_picker.is_some() {
handle_session_picker_input(app, key);
return;
}
if tui.has_overlays() && matches!(key.code, crossterm::event::KeyCode::Esc) {
tui.pop_overlay();
return;
}
if tui.has_overlays() {
return;
}
if tui.root.handle_input(key) {
return;
}
let action = app.editor.borrow_mut().handle_input(key);
match action {
InputAction::Handled => {}
InputAction::Escape => {
if app.is_streaming {
interrupt_streaming(app);
} else {
app.editor.borrow_mut().editor.set_text("");
}
}
InputAction::Clear => {
handle_clear(app);
}
InputAction::Exit => {
app.should_quit = true;
}
InputAction::ThinkingCycle => {
handle_thinking_cycle(app);
}
InputAction::ModelSelector => {
open_model_selector(app, tui);
}
InputAction::ModelCycleForward => {
handle_model_cycle(app, 1);
}
InputAction::ModelCycleBackward => {
handle_model_cycle(app, -1);
}
InputAction::ToggleThinking => {
app.hide_thinking = !app.hide_thinking;
app.propagate_hide_thinking();
app.settings.set_hide_thinking(Some(app.hide_thinking));
if let Err(e) = app.settings.save() {
app.status_text = Some(format!("Failed to save thinking visibility: {}", e));
}
show_status(
app,
if app.hide_thinking {
"Thinking blocks: hidden".to_string()
} else {
"Thinking blocks: visible".to_string()
},
);
}
InputAction::ToolsExpand => {
handle_tools_expand(app);
}
InputAction::EditorExternal => {
handle_editor_external(app, tui, term);
}
InputAction::Help => {
show_help_overlay(app, tui);
}
InputAction::Submit(text) => {
submit_message(app, text);
}
InputAction::FollowUp(text) => {
handle_follow_up(app, text);
}
InputAction::Dequeue => {
if let Some(msg) = app.pending_submit.take() {
app.editor.borrow_mut().editor.set_text(&msg);
app.status_text = Some("Queued message restored to editor".into());
} else {
app.status_text = Some("No queued message".into());
}
}
InputAction::CompactToggle => {
handle_compact_toggle(app);
}
}
}
fn handle_clear(app: &mut App) {
let now = std::time::Instant::now();
let elapsed = now.duration_since(app.last_clear_time);
app.last_clear_time = now;
if app.is_streaming {
interrupt_streaming(app);
} else if elapsed.as_millis() < 500 {
app.should_quit = true;
} else {
app.editor.borrow_mut().editor.set_text("");
app.status_text = Some("Cleared".into());
}
}
fn handle_thinking_cycle(app: &mut App) {
if app.available_models.is_empty() && app.model.is_empty() {
app.status_text = Some("No model selected".into());
return;
}
let levels = available_thinking_levels(app);
if levels.is_empty() {
return;
}
let current = app.thinking_level.as_deref().unwrap_or("off");
let next = match levels.iter().position(|&l| l == current) {
Some(pos) => levels[(pos + 1) % levels.len()],
None => "off",
};
app.thinking_level = Some(next.to_string());
app.editor
.borrow_mut()
.update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
app.settings
.set_default_thinking_level(Some(next.to_string()));
if let Err(e) = app.settings.save() {
app.status_text = Some(format!("Failed to save thinking level: {}", e));
}
if let Some(ref mut agent_session) = app.session {
agent_session.on_thinking_level_change(next);
}
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
show_status(app, format!("Thinking level: {}", next));
}
fn handle_model_cycle(app: &mut App, dir: isize) {
let authenticated_models = app.registry.list_authenticated_model_ids();
let model_pool: Vec<String> = if let Some(ref scoped) = app.scoped_model_ids
&& !scoped.is_empty()
{
scoped
.iter()
.filter_map(|full_id| {
let (_provider, model_id) = full_id.split_once('/')?;
if authenticated_models.iter().any(|m| m == model_id) {
Some(model_id.to_string())
} else {
None
}
})
.collect()
} else {
authenticated_models
};
let n = model_pool.len();
if n == 0 {
app.status_text = Some("No models available".into());
return;
}
let current_idx = model_pool.iter().position(|m| m == &app.model);
let next_idx = match current_idx {
Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
None => 0,
};
let model = model_pool[next_idx].clone();
app.model = model.clone();
app.current_provider = app
.registry
.provider_for_model(&model, Some(&app.current_provider))
.unwrap_or_default();
app.record_model_change(&model);
show_status(app, format!("Model: {}", app.model));
}
fn handle_tools_expand(app: &mut App) {
app.tools_expanded = !app.tools_expanded;
app.collapse_tool_output = !app.tools_expanded;
app.header.borrow_mut().set_expanded(app.tools_expanded);
let mut chat = app.chat_container.borrow_mut();
for child in chat.children_mut().iter_mut() {
child.set_expanded(app.tools_expanded);
}
drop(chat);
app.settings
.set_collapse_tool_output(Some(app.collapse_tool_output));
if let Err(e) = app.settings.save() {
app.status_text = Some(format!("Failed to save tool output setting: {}", e));
}
show_status(
app,
if app.tools_expanded {
"Tool output: expanded".to_string()
} else {
"Tool output: collapsed".to_string()
},
);
}
fn handle_editor_external(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal) {
let editor_cmd = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_default();
if editor_cmd.is_empty() {
app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
return;
}
let tmp_dir = std::env::temp_dir();
let tmp_file = tmp_dir.join(format!(
"rab-editor-{}.md",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let current_text = app.editor.borrow().editor.get_text();
if let Err(e) = std::fs::write(&tmp_file, ¤t_text) {
app.status_text = Some(format!("Failed to write temp file: {}", e));
return;
}
let parts: Vec<&str> = editor_cmd.split(' ').collect();
let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
app.status_text = Some(format!("Opening {} ...", editor_cmd));
let mut suspend_buf = Vec::new();
let _ = term.stop(&mut suspend_buf);
let _ = term.show_cursor(&mut suspend_buf);
if !suspend_buf.is_empty() {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
let _ = handle.write_all(&suspend_buf);
let _ = handle.flush();
}
crate::tui::terminal::stop_stdin_reader();
crate::tui::terminal::join_stdin_reader();
let status = std::process::Command::new(editor)
.args(args)
.arg(&tmp_file)
.status();
let mut resume_buf = Vec::new();
let _ = term.start(&mut resume_buf);
let _ = term.hide_cursor(&mut resume_buf);
if !resume_buf.is_empty() {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
let _ = handle.write_all(&resume_buf);
let _ = handle.flush();
}
crate::tui::terminal::start_stdin_reader();
tui.request_render();
match status {
Ok(status) if status.success() => {
if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
let trimmed = new_content.trim_end_matches('\n').to_string();
app.editor.borrow_mut().editor.set_text(&trimmed);
app.editor.borrow_mut().check_autocomplete();
}
let _ = std::fs::remove_file(&tmp_file);
app.status_text = Some("Editor closed".into());
}
Ok(_) => {
let _ = std::fs::remove_file(&tmp_file);
app.status_text = Some("Editor exited with non-zero status".into());
}
Err(e) => {
let _ = std::fs::remove_file(&tmp_file);
app.status_text = Some(format!("Failed to launch editor: {}", e));
}
}
}
fn handle_compact_toggle(app: &mut App) {
app.auto_compact = !app.auto_compact;
app.footer.borrow_mut().set_auto_compact(app.auto_compact);
if let Some(ref mut s) = app.session {
s.set_auto_compact(app.auto_compact);
}
app.settings.set_auto_compact(Some(app.auto_compact));
if let Err(e) = app.settings.save() {
eprintln!("Warning: failed to save auto_compact setting: {}", e);
}
app.status_text = Some(if app.auto_compact {
"Auto-compact: on".into()
} else {
"Auto-compact: off".into()
});
}
pub fn handle_follow_up(app: &mut App, text: String) {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return;
}
if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
let follow_msg = user_agent_message(&trimmed);
if let Some(ref agent) = app.agent {
agent.follow_up(follow_msg);
app.status_text = Some("Follow-up queued — will send when agent finishes".into());
}
} else {
if app.is_streaming {
app.is_streaming = false;
}
submit_message(app, trimmed);
}
}
fn interrupt_streaming(app: &mut App) {
if let Some(ref agent) = app.agent {
agent.abort();
}
if let Some(handle) = app.forward_handle.take() {
handle.abort();
}
if let Some(handle) = app.bash_abort_handle.take() {
handle.abort();
}
app.agent = None;
app.is_streaming = false;
app.working.stop();
app.footer.borrow_mut().set_streaming(false);
if let Some(ref s) = app.session {
let ctx = s.session().build_session_context();
let mut chat = app.chat_container.borrow_mut();
rebuild_chat_from_messages(
&mut chat,
&ctx.messages,
&app.cwd.to_string_lossy(),
app.hide_thinking,
app.collapse_tool_output,
&app.extensions,
);
}
app.status_text = Some("Interrupted".into());
}
fn handle_login(app: &mut App, provider: &str, api_key: Option<&str>) {
let provider = if provider.is_empty() {
"opencode-go"
} else {
provider
};
if let Some(key) = api_key {
match auth::login(provider, key) {
Ok(_) => {
app.refresh_registry();
complete_login(
app,
provider,
crate::agent::ui::components::oauth_selector::AuthType::ApiKey,
);
}
Err(e) => chat_info(app, format!("Login failed: {}", e)),
}
} else {
chat_info(app, format!("Usage: /login {} <api-key>", provider));
}
}
fn handle_logout(app: &mut App, provider: Option<&str>) {
match auth::logout(provider) {
Ok(true) => {
let msg = provider
.map(|p| format!("Logged out from {}", p))
.unwrap_or_else(|| "Logged out from all providers".into());
chat_info(app, msg);
}
Ok(false) => {
let msg = provider
.map(|p| format!("No credentials for {}", p))
.unwrap_or_else(|| "No credentials found".into());
chat_info(app, msg);
}
Err(e) => {
chat_info(app, format!("Logout failed: {}", e));
}
}
}
fn show_login_provider_selector(app: &mut App, tui: &mut TUI, auth_type: Option<AuthType>) {
use crate::agent::ui::components::oauth_selector::{
AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
};
let all_providers = app.registry.list_providers();
let mut providers: Vec<AuthSelectorProvider> = Vec::new();
for (id, name) in all_providers {
let is_oauth_provider = crate::provider::oauth::get(&id).is_some();
match auth_type {
Some(AuthType::ApiKey) => {
if !is_oauth_provider {
providers.push(AuthSelectorProvider {
id,
name,
auth_type: AuthType::ApiKey,
});
}
}
Some(AuthType::OAuth) => {
if is_oauth_provider {
providers.push(AuthSelectorProvider {
id,
name,
auth_type: AuthType::OAuth,
});
}
}
None => {
providers.push(AuthSelectorProvider {
id,
name,
auth_type: if is_oauth_provider {
AuthType::OAuth
} else {
AuthType::ApiKey
},
});
}
}
}
if auth_type != Some(AuthType::ApiKey) {
for oauth_id in crate::provider::oauth::list_ids() {
if !providers.iter().any(|p| p.id == oauth_id)
&& let Some(provider) = crate::provider::oauth::get(&oauth_id)
{
providers.push(AuthSelectorProvider {
id: oauth_id,
name: provider.name().to_string(),
auth_type: AuthType::OAuth,
});
}
}
}
providers.sort_by_key(|a| a.name.to_lowercase());
if providers.is_empty() {
app.status_text = Some(match auth_type {
Some(AuthType::OAuth) => "No subscription providers available.".into(),
Some(AuthType::ApiKey) => "No API key providers available.".into(),
None => "No providers available.".into(),
});
return;
}
let signal = app.overlay_result_signal.clone();
let mut selector = OAuthSelector::new(
providers,
|provider_id| app.registry.auth_status_for_provider(provider_id),
SelectorMode::Login,
);
selector.on_select(move |provider_id: String| {
*signal.borrow_mut() = Some(OverlayResult::LoginProviderSelected(provider_id));
});
selector.on_cancel(|| {});
tui.show_top_overlay(Box::new(selector));
}
fn show_api_key_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
use crate::agent::ui::components::LoginDialog;
let provider_name = app
.registry
.list_providers()
.into_iter()
.find(|(id, _)| id == provider_id)
.map(|(_, name)| name)
.unwrap_or_else(|| provider_id.to_string());
let mut dialog = LoginDialog::new(provider_id.to_string(), provider_name.clone());
let signal = app.overlay_result_signal.clone();
let provider_id_clone = provider_id.to_string();
dialog.on_submit(move |api_key: String| {
*signal.borrow_mut() = Some(OverlayResult::LoginApiKeyProvided {
provider: provider_id_clone,
key: api_key,
});
});
dialog.on_cancel(|| {});
dialog.show_prompt("Enter API key:", Some("sk-..."));
tui.show_top_overlay(Box::new(dialog));
}
fn show_oauth_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
let provider_name = app
.registry
.list_providers()
.into_iter()
.find(|(id, _)| id == provider_id)
.map(|(_, name)| name)
.unwrap_or_else(|| {
crate::provider::oauth::get(provider_id)
.map(|p| p.name().to_string())
.unwrap_or_else(|| provider_id.to_string())
});
app.status_text = Some(format!("Starting OAuth login for {}…", provider_name));
tui.pop_overlay();
let tx = app.event_tx.clone();
let pid = provider_id.to_string();
let pname = provider_name.clone();
let tx2 = tx.clone();
let tx3 = tx.clone();
let tx4 = tx.clone();
app.pending_oauth_provider = Some(pid.clone());
let handle = tokio::spawn(async move {
let oauth_provider = match crate::provider::oauth::get(&pid) {
Some(p) => p,
None => {
let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: format!(
"OAuth login failed: No OAuth provider registered for '{}'",
pid
),
});
return;
}
};
let mut callbacks = crate::provider::oauth::OAuthLoginCallbacks {
on_device_code: Box::new(move |info: crate::provider::oauth::DeviceCodeInfo| {
let device_msg = format!(
"Open {} and enter code: {}",
info.verification_uri, info.user_code
);
let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: device_msg,
});
}),
on_prompt: Box::new(
move |prompt: crate::provider::oauth::OAuthPrompt| match prompt {
crate::provider::oauth::OAuthPrompt::Text {
message,
placeholder: _,
allow_empty: _,
} => {
let _ = tx2.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: format!("{} (empty = github.com)", message),
});
Ok(String::new())
}
},
),
on_progress: Box::new(move |msg: String| {
let _ = tx3.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: format!("[OAuth] {}", msg),
});
}),
signal: None,
};
match oauth_provider.login(&mut callbacks).await {
Ok(credentials) => {
let cred = crate::auth::AuthCredential::Oauth {
access: credentials.access.clone(),
refresh: Some(credentials.refresh.clone()),
expires: Some(credentials.expires),
enterprise_url: credentials.enterprise_url.clone(),
};
match crate::auth::login_oauth(&pid, &cred) {
Ok(_) => {
let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: format!("✓ Logged in to {} via OAuth", pname),
});
}
Err(e) => {
let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: format!("Failed to save OAuth credentials: {}", e),
});
}
}
}
Err(e) => {
let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
tool_call_id: String::new(),
tool_name: String::new(),
text: format!("OAuth login failed: {}", e),
});
}
}
});
app.oauth_join_handle = Some(handle);
}
fn show_auth_type_selector(app: &mut App, tui: &mut TUI) {
let signal = app.overlay_result_signal.clone();
let _theme = crate::agent::ui::theme::current_theme().clone();
let mut items = vec![crate::tui::components::select_list::SelectItem::new(
"api_key",
"Use an API key",
)];
let has_oauth = !crate::provider::oauth::list_ids().is_empty();
if has_oauth {
items.push(crate::tui::components::select_list::SelectItem::new(
"oauth",
"Use a subscription",
));
}
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let selected_index: usize = 0;
struct AuthTypeOverlay {
items: Vec<crate::tui::components::select_list::SelectItem>,
selected_index: usize,
filtered_indices: Vec<usize>,
signal: std::rc::Rc<std::cell::RefCell<Option<OverlayResult>>>,
}
impl crate::tui::Component for AuthTypeOverlay {
fn render(&mut self, width: usize) -> Vec<String> {
let theme = crate::agent::ui::theme::current_theme();
let mut lines = Vec::new();
lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
lines.push(String::new());
lines.push(format!(
" {}",
theme.bold(&theme.fg_key(ThemeKey::Accent, "Select authentication method:"))
));
lines.push(String::new());
for (i, &item_idx) in self.filtered_indices.iter().enumerate() {
let item = &self.items[item_idx];
let is_selected = i == self.selected_index;
let prefix = if is_selected {
theme.fg_key(ThemeKey::Accent, "→ ")
} else {
" ".to_string()
};
let text = if is_selected {
theme.fg_key(ThemeKey::Accent, &item.label)
} else {
theme.fg_key(ThemeKey::Text, &item.label)
};
lines.push(format!("{}{}", prefix, text));
}
lines.push(String::new());
lines.push(format!(" {}", theme.dim("Enter: select · Esc: cancel")));
lines.push(String::new());
lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
lines
}
fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
let kb = crate::tui::keybindings::get_keybindings();
if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_UP) {
if self.filtered_indices.is_empty() {
return true;
}
self.selected_index = if self.selected_index == 0 {
self.filtered_indices.len() - 1
} else {
self.selected_index - 1
};
return true;
}
if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_DOWN) {
if self.filtered_indices.is_empty() {
return true;
}
self.selected_index = if self.selected_index >= self.filtered_indices.len() - 1 {
0
} else {
self.selected_index + 1
};
return true;
}
if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CONFIRM) {
if let Some(&idx) = self.filtered_indices.get(self.selected_index) {
let value = self.items[idx].value.clone();
let auth_type = match value.as_str() {
"oauth" => AuthType::OAuth,
_ => AuthType::ApiKey,
};
*self.signal.borrow_mut() =
Some(OverlayResult::LoginAuthTypeSelected(auth_type));
}
return true;
}
if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CANCEL) {
return true;
}
false
}
}
let overlay = AuthTypeOverlay {
items,
selected_index,
filtered_indices,
signal: signal.clone(),
};
tui.show_top_overlay(Box::new(overlay));
}
fn show_auth_type_or_provider_selector(app: &mut App, tui: &mut TUI) {
let providers = app.registry.list_providers();
if providers.is_empty() {
app.status_text = Some("No providers available for login.".into());
return;
}
let has_oauth = !crate::provider::oauth::list_ids().is_empty();
let has_api_key = providers.iter().any(|(_, _)| true);
if has_oauth && has_api_key {
show_auth_type_selector(app, tui);
} else if has_oauth {
show_login_provider_selector(app, tui, Some(AuthType::OAuth));
} else {
show_login_provider_selector(app, tui, Some(AuthType::ApiKey));
}
}
fn show_logout_provider_selector(app: &mut App, tui: &mut TUI) {
use crate::agent::ui::components::oauth_selector::{
AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
};
let logged_in = auth::list_logged_in().unwrap_or_default();
if logged_in.is_empty() {
app.status_text = Some(
"No stored credentials to remove. /logout only removes credentials saved by /login; \
environment variables and models.json config are unchanged."
.into(),
);
return;
}
let mut providers: Vec<AuthSelectorProvider> = logged_in
.into_iter()
.filter_map(|id| {
app.registry
.list_providers()
.into_iter()
.find(|(pid, _)| pid == &id)
.map(|(pid, name)| AuthSelectorProvider {
id: pid,
name,
auth_type: AuthType::ApiKey,
})
})
.collect();
providers.sort_by_key(|a| a.name.to_lowercase());
if providers.is_empty() {
app.status_text = Some("No registered providers with stored credentials.".into());
return;
}
let signal = app.overlay_result_signal.clone();
let mut selector = OAuthSelector::new(
providers,
|provider_id| app.registry.auth_status_for_provider(provider_id),
SelectorMode::Logout,
);
selector.on_select(move |provider_id: String| {
*signal.borrow_mut() = Some(OverlayResult::LogoutProviderSelected(provider_id));
});
selector.on_cancel(|| {});
tui.show_top_overlay(Box::new(selector));
}
fn complete_login(app: &mut App, provider_id: &str, _auth_type: AuthType) {
let available_models = app.registry.list_model_provider_tuples();
let provider_models: Vec<&str> = available_models
.iter()
.filter(|(pid, _, _)| pid == provider_id)
.map(|(_, mid, _)| mid.as_str())
.collect();
if provider_models.is_empty() {
app.status_text = Some(format!(
"Saved API key for {provider_id}. No models available for this provider. Use /model to select a model."
));
return;
}
let current_provider = app
.registry
.provider_for_model(&app.model, Some(&app.current_provider))
.unwrap_or_default();
if current_provider != provider_id || !app.available_models.contains(&app.model) {
let first_model = provider_models[0];
app.model = first_model.to_string();
app.current_provider = provider_id.to_string();
let model = app.model.clone();
app.record_model_change(&model);
app.status_text = Some(format!(
"Saved API key for {provider_id}. Selected {first_model}."
));
} else {
app.status_text = Some(format!("Saved API key for {provider_id}."));
}
}
fn open_model_selector(app: &mut App, tui: &mut TUI) {
let current = app.model.clone();
let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
let all_models: Vec<(String, String, String)> = all_tuples
.into_iter()
.filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
.collect();
let scoped_ids = app.scoped_model_ids.clone().unwrap_or_default();
let signal = app.overlay_result_signal.clone();
let current_provider = app
.registry
.provider_for_model(¤t, Some(&app.current_provider))
.unwrap_or_else(|| "unknown".to_string());
let current_full_id = format!("{}/{}", current_provider, current);
let callbacks = crate::agent::ui::model_selector::ModelSelectorCallbacks {
on_select: Box::new({
let signal = signal.clone();
move |full_id: String| {
*signal.borrow_mut() = Some(OverlayResult::ModelSelected(full_id));
}
}),
on_cancel: Box::new(|| {}), };
let selector = crate::agent::ui::model_selector::ModelSelector::new(
all_models,
scoped_ids,
current_full_id,
callbacks,
);
tui.show_top_overlay(Box::new(selector));
}
fn open_scoped_models_selector(app: &mut App, tui: &mut TUI) {
use crate::agent::ui::components::scoped_models_selector::{
ModelsCallbacks, ModelsConfig, ScopedModelsSelector,
};
let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
let all_models: Vec<(String, String, String)> = all_tuples
.into_iter()
.filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
.collect();
let current_enabled = app.scoped_model_ids.clone();
let change_signal = app.pending_scoped_ids.clone();
let close_signal = app.overlay_result_signal.clone();
let callbacks = ModelsCallbacks {
on_change: Box::new(move |enabled_ids: Option<Vec<String>>| {
*change_signal.borrow_mut() = Some(enabled_ids.unwrap_or_default());
}),
on_persist: Box::new({
let cs = close_signal.clone();
move |enabled_ids: Option<Vec<String>>| {
*cs.borrow_mut() = Some(OverlayResult::ScopedModelsAccepted(enabled_ids));
}
}),
on_cancel: Box::new(move || {
*close_signal.borrow_mut() = Some(OverlayResult::ScopedModelsCancelled);
}),
};
let config = ModelsConfig {
all_models,
enabled_model_ids: current_enabled,
};
let selector = ScopedModelsSelector::new(config, callbacks);
tui.show_top_overlay(Box::new(selector));
}
fn show_help_overlay(app: &mut App, tui: &mut TUI) {
let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
overlay.set_commands(app.commands.clone());
tui.show_overlay(Box::new(overlay), Default::default());
}
fn submit_message(app: &mut App, message: String) {
app.scroll_offset = 0;
let trimmed = message.trim().to_string();
if trimmed.is_empty() {
return;
}
let after_skill = if trimmed.starts_with("/skill:") {
expand_skill_command(&trimmed, &app.skills)
} else {
trimmed.clone()
};
let expanded =
crate::agent::prompt_templates::expand_prompt_template(&after_skill, &app.prompt_templates);
if expanded != after_skill || after_skill != trimmed {
if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
let steer_msg = user_agent_message(&expanded);
if let Some(ref agent) = app.agent {
agent.steer(steer_msg);
app.status_text = Some("Skill/template steering message sent".into());
}
return;
}
if app.is_streaming {
app.is_streaming = false;
app.working.stop();
app.footer.borrow_mut().set_streaming(false);
}
app.pending_submit = Some(expanded);
return;
}
if trimmed.starts_with('/') {
handle_slash_command(app, &trimmed);
return;
}
if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
handle_bang_command(app, cmd);
return;
}
if app.is_streaming {
if app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
let steer_msg = user_agent_message(&trimmed);
if let Some(ref agent) = app.agent {
agent.steer(steer_msg);
app.status_text = Some("Steering message sent — will be processed next".into());
}
if let Some(ref mut s) = app.session {
s.reset_overflow_recovery();
}
return; } else {
app.is_streaming = false;
app.working.stop();
app.footer.borrow_mut().set_streaming(false);
}
}
if let Some(ref mut s) = app.session {
s.reset_overflow_recovery();
}
app.pending_submit = Some(trimmed);
}
#[allow(clippy::too_many_arguments)]
fn build_fresh_agent(
registry: &ProviderRegistry,
model: &str,
api_key: &str,
system_prompt: &str,
thinking_level: yoagent::types::ThinkingLevel,
messages: Vec<yoagent::types::AgentMessage>,
extensions: &[Box<dyn Extension>],
default_provider: Option<&str>,
) -> yoagent::agent::Agent {
use yoagent::provider::model::ApiProtocol;
let resolved = registry.resolve(model, default_provider).ok();
let mc = resolved
.as_ref()
.map(|r| r.model_config.clone())
.unwrap_or_else(|| crate::agent::base_model_config(model));
let api_key = resolved
.as_ref()
.map(|r| r.api_key.as_str())
.unwrap_or(api_key);
let tools: Vec<Box<dyn yoagent::types::AgentTool>> = extensions
.iter()
.flat_map(|ext| ext.tools())
.map(|twm| Box::new(twm) as Box<dyn yoagent::types::AgentTool>)
.collect();
let agent = match mc.api {
ApiProtocol::OpenAiCompletions => {
yoagent::agent::Agent::new(crate::provider::openai_compat::RabOpenAiCompatProvider)
}
ApiProtocol::AnthropicMessages => {
yoagent::agent::Agent::new(crate::provider::anthropic::RabAnthropicProvider)
}
ApiProtocol::OpenAiResponses => {
yoagent::agent::Agent::new(yoagent::provider::OpenAiResponsesProvider)
}
ApiProtocol::GoogleGenerativeAi => {
yoagent::agent::Agent::new(yoagent::provider::GoogleProvider)
}
_ => yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider),
};
agent
.with_model(model)
.with_api_key(api_key)
.with_model_config(mc)
.with_system_prompt(system_prompt)
.with_thinking(thinking_level)
.with_messages(messages)
.with_tools(tools)
.without_context_management()
}
fn map_thinking_level(level: Option<&str>) -> yoagent::types::ThinkingLevel {
match level {
Some("off") => yoagent::types::ThinkingLevel::Off,
Some("low") => yoagent::types::ThinkingLevel::Low,
Some("medium") => yoagent::types::ThinkingLevel::Medium,
Some("high") | Some("xhigh") => yoagent::types::ThinkingLevel::High,
_ => yoagent::types::ThinkingLevel::High,
}
}
async fn start_agent_loop(app: &mut App, message: String) {
if app.session.is_none() {
return;
}
app.is_streaming = true;
app.working.start();
app.footer.borrow_mut().set_streaming(true);
let thinking = map_thinking_level(app.thinking_level.as_deref());
let msgs = app
.session
.as_ref()
.map(|s| s.session().build_session_context().messages)
.unwrap_or_default();
let model = app.model.clone();
app.record_model_change(&model);
if let Some(ref mut session) = app.session {
session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
}
let agent: &mut yoagent::agent::Agent = match &mut app.agent {
Some(existing) => {
existing
}
None => {
let preferred = if !app.current_provider.is_empty() {
Some(app.current_provider.as_str())
} else {
app.settings.default_provider.as_deref()
};
app.agent = Some(build_fresh_agent(
&app.registry,
&app.model,
&app.api_key,
&app.system_prompt,
thinking,
msgs,
&app.extensions,
preferred,
));
app.agent.as_mut().unwrap()
}
};
let mut rx = agent.prompt(message).await;
let tx = app.event_tx.clone();
let handle = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
if tx.send(event).is_err() {
break;
}
}
});
app.forward_handle = Some(handle);
}
async fn handle_compact_command(app: &mut App, custom_instructions: Option<String>) {
if app.session.is_none() {
chat_info(app, "No active session to compact".to_string());
return;
}
let agent_session = app.session.as_mut().unwrap();
app.working.start();
match agent_session
.run_manual_compact(custom_instructions.as_deref())
.await
{
Ok(_summary) => {
app.working.stop();
app.status_text = None;
app.rebuild_from_session_context();
show_status(app, "Compaction completed".to_string());
}
Err(e) => {
app.working.stop();
app.status_text = None;
chat_info(app, format!("Compaction failed: {}", e));
}
}
}
async fn handle_auto_compact(app: &mut App) {
if app.session.is_none() {
return;
}
let agent_session = app.session.as_mut().unwrap();
match agent_session.check_auto_compact().await {
Ok(true) => {
app.rebuild_from_session_context();
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
app.status_text = Some("Auto-compaction completed".to_string());
}
Ok(false) => {
}
Err(e) => {
eprintln!("Warning: Auto-compaction failed: {}", e);
app.status_text = Some(format!("Auto-compaction skipped: {}", e));
}
}
}
fn handle_session_picker_input(app: &mut App, key: &crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
let Some(ref mut picker) = app.session_picker else {
return;
};
match key.code {
KeyCode::Esc => {
app.session_picker = None;
app.status_text = None;
}
KeyCode::Enter => {
if let Some(path) = picker.selected_path() {
let path = path.clone();
app.session_picker = None;
app.status_text = None;
app.pending_command_result = Some(CommandResult::SessionSwitched { path });
}
}
KeyCode::Up => {
picker.select_prev();
}
KeyCode::Down => {
picker.select_next();
}
KeyCode::Char('/') => {
picker.set_filter("");
}
KeyCode::Char(c) => {
let mut filter = picker.filter().to_string();
filter.push(c);
picker.set_filter(&filter);
}
KeyCode::Backspace => {
let mut filter = picker.filter().to_string();
filter.pop();
picker.set_filter(&filter);
}
_ => {}
}
}
fn handle_slash_command(app: &mut App, input: &str) {
let (cmd_name, args) = match input.split_once(' ') {
Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
None => (input.trim_start_matches('/'), ""),
};
for ext in app.extensions.iter() {
for cmd in ext.commands() {
if cmd.name == cmd_name {
let result = cmd.handler.execute(args);
match result {
Ok(result) => {
drop((ext, cmd));
handle_command_result(app, result);
return;
}
Err(e) => {
drop((ext, cmd));
chat_info(app, format!("Error executing /{}: {}", cmd_name, e));
return;
}
}
}
}
}
let available: Vec<&str> = app.commands.iter().map(|(n, _)| n.as_str()).collect();
app.status_text = Some(format!(
"Unknown command: /{}. Available: {}",
cmd_name,
available.join(", ")
));
}
fn handle_command_result(app: &mut App, result: CommandResult) {
match result {
CommandResult::Info(msg) => {
chat_info(app, msg.clone());
}
CommandResult::Quit => {
app.should_quit = true;
}
CommandResult::ModelChanged(model) => {
app.model = model.clone();
app.current_provider = app
.registry
.provider_for_model(&model, Some(&app.current_provider))
.unwrap_or_default();
app.record_model_change(&model);
app.status_text = Some(format!("Model: {}", model));
}
CommandResult::ShowHelp => {
app.pending_command_result = Some(result);
}
CommandResult::Reloaded => {
app.refresh_registry();
{
let models = app.registry.list_models();
app.available_models = models.clone();
for ext in app.extensions.iter() {
if let Some(cmd) = ext
.as_any()
.downcast_ref::<crate::builtin::commands::CommandsExtension>()
{
cmd.set_available_models(models.clone());
break;
}
}
}
for ext in app.extensions.iter() {
ext.on_session_shutdown("reload");
}
let mut reload_parts: Vec<&str> = Vec::new();
match app.settings.reload(&app.cwd) {
Err(e) => {
app.status_text = Some(format!("Failed to reload settings: {}", e));
}
Ok(()) => {
reload_parts.push("settings");
if let Some(level) = app.settings.default_thinking_level.clone() {
app.thinking_level = Some(level.clone());
if let Some(ref mut s) = app.session {
s.on_thinking_level_change(&level);
}
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
}
app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
app.propagate_hide_thinking();
app.editor.borrow_mut().update_border_color(
app.thinking_level.as_deref(),
&app.theme as &dyn crate::tui::Theme,
);
app.auto_compact = app.settings.auto_compact.unwrap_or(true);
if let Some(ref mut s) = app.session {
s.set_auto_compact(app.auto_compact);
}
app.footer.borrow_mut().set_auto_compact(app.auto_compact);
app.collapse_tool_output = app.settings.collapse_tool_output.unwrap_or(false);
app.tools_expanded = !app.collapse_tool_output;
if let Some(ref theme_name) = app.settings.theme
&& crate::agent::ui::theme::set_theme(theme_name).is_ok()
{
app.theme = crate::agent::ui::theme::current_theme().clone();
reload_parts.push("theme");
}
}
}
let mut kb = crate::tui::keybindings::Keybindings::with_defaults();
if let Some(home) = directories::BaseDirs::new()
.map(|d| d.home_dir().join(".rab").join("keybindings.json"))
&& home.exists()
{
match crate::tui::keybindings::Keybindings::load(&home) {
Ok(custom) => kb.merge(custom),
Err(e) => {
app.status_text = Some(format!("Failed to load keybindings: {}", e));
}
}
}
crate::tui::keybindings::init_keybindings(kb);
reload_parts.push("keybindings");
let new_skill_set =
yoagent::skills::SkillSet::load(&app.skill_dirs).unwrap_or_default();
app.skills = new_skill_set.skills().to_vec();
reload_parts.push("skills");
app.prompt_templates =
crate::agent::prompt_templates::load_prompt_templates(&app.prompt_template_dirs);
if !app.prompt_template_dirs.is_empty() {
reload_parts.push("prompts");
}
let context_files =
crate::agent::context_files::load_context_files(&app.cwd, &app.agent_dir);
let custom_system_md = {
let project_path = app.cwd.join(".rab").join("SYSTEM.md");
if project_path.exists() {
std::fs::read_to_string(&project_path).ok()
} else {
let global_path = app.agent_dir.join("SYSTEM.md");
if global_path.exists() {
std::fs::read_to_string(&global_path).ok()
} else {
None
}
}
};
let append_system_md = {
let project_path = app.cwd.join(".rab").join("APPEND_SYSTEM.md");
if project_path.exists() {
std::fs::read_to_string(&project_path).ok()
} else {
let global_path = app.agent_dir.join("APPEND_SYSTEM.md");
if global_path.exists() {
std::fs::read_to_string(&global_path).ok()
} else {
None
}
}
};
let all_tools: Vec<crate::agent::extension::ToolDefinition> =
app.extensions.iter().flat_map(|ext| ext.tools()).collect();
let tool_snippets: Vec<crate::agent::ToolSnippet> = all_tools
.iter()
.map(|twm| crate::agent::ToolSnippet {
name: twm.name().to_string(),
description: twm.snippet.to_string(),
})
.collect();
let has_read_tool = tool_snippets.iter().any(|t| t.name == "read");
let new_system_prompt = crate::agent::SystemPromptBuilder::new()
.tool_snippets(tool_snippets)
.context_files(context_files.clone())
.custom_prompt(custom_system_md)
.append_prompt(append_system_md)
.skills(new_skill_set)
.has_read_tool(has_read_tool)
.cwd(&app.cwd)
.build();
app.system_prompt = new_system_prompt;
let context_file_list: Vec<String> = context_files
.iter()
.map(|cf| {
let cwd_str = app.cwd.to_string_lossy();
if let Some(rel) = cf.path.to_string_lossy().strip_prefix(&cwd_str as &str) {
if rel.is_empty() {
cf.path.to_string_lossy().to_string()
} else {
format!("./{}", rel.trim_start_matches('/'))
}
} else if let Some(home) =
std::env::var_os("HOME").and_then(|h| h.into_string().ok())
&& let Some(rel) = cf.path.to_string_lossy().strip_prefix(&home)
{
if rel.is_empty() {
cf.path.to_string_lossy().to_string()
} else {
format!("~/{}", rel.trim_start_matches('/'))
}
} else {
cf.path.to_string_lossy().to_string()
}
})
.collect();
app.context_files = context_file_list.clone();
{
let skill_names: Vec<String> = app.skills.iter().map(|s| s.name.clone()).collect();
let template_names: Vec<String> = app
.prompt_templates
.iter()
.map(|t| t.name.clone())
.collect();
let extension_names: Vec<String> = app
.extensions
.iter()
.map(|e| e.name().to_string())
.collect();
let theme_names: Vec<String> = crate::agent::ui::theme::get_available_themes()
.into_iter()
.filter(|n| n != "dark" && n != "light")
.collect();
app.header.borrow_mut().set_resource_data(
context_file_list,
skill_names,
template_names,
extension_names,
theme_names,
);
}
reload_parts.push("system prompt");
reload_parts.push("context files");
{
use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
let mut auto_commands: Vec<AutoSlashCommand> =
app.extensions
.iter()
.flat_map(|e| e.commands())
.map(|cmd| {
let handler = cmd.handler;
AutoSlashCommand {
name: cmd.name,
description: Some(cmd.description),
argument_hint: None,
argument_completions: None,
get_argument_completions: Some(
std::sync::Arc::new(
move |prefix: &str| -> Vec<
crate::tui::autocomplete::AutocompleteItem,
> {
handler
.argument_completions(prefix)
.into_iter()
.map(|item| {
crate::tui::autocomplete::AutocompleteItem {
value: item.value,
label: item.label,
description: item.description,
}
})
.collect()
},
),
),
}
})
.collect();
for skill in &app.skills {
let cmd_name = format!("skill:{}", skill.name);
auto_commands.push(AutoSlashCommand {
name: cmd_name,
description: Some(skill.description.clone()),
argument_hint: None,
argument_completions: None,
get_argument_completions: None,
});
}
for template in &app.prompt_templates {
auto_commands.push(AutoSlashCommand {
name: template.name.clone(),
description: Some(template.description.clone()),
argument_hint: template.argument_hint.clone(),
argument_completions: None,
get_argument_completions: None,
});
}
app.editor.borrow_mut().set_slash_commands(auto_commands);
}
app.commands = app
.extensions
.iter()
.flat_map(|e| e.commands())
.map(|c| (c.name, c.description))
.collect();
for skill in &app.skills {
app.commands
.push((format!("skill:{}", skill.name), skill.description.clone()));
}
for template in &app.prompt_templates {
app.commands
.push((template.name.clone(), template.description.clone()));
}
for ext in app.extensions.iter() {
ext.on_session_start("reload");
}
chat_info(app, format!("{} reloaded.", reload_parts.join(", ")));
}
CommandResult::NewSession => {
app.working.stop();
app.status_text = None;
if let Some(ref mut agent_session) = app.session {
agent_session.new_session();
}
app.agent = None;
app.clear_session_state();
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
let styled = app.theme.fg("accent", "✓ New session started");
chat_add(app, std::boxed::Box::new(Text::new(styled, 1, 1, None)));
}
CommandResult::SessionSwitched { path } => {
let new_session = crate::agent::AgentSession::open(&path, None, Some(&app.cwd));
app.switch_to_session(new_session);
app.status_text = Some(format!("Switched to session: {}", path.display()));
}
CommandResult::SessionInfo {
session_id,
file_path,
name,
message_count,
user_messages,
assistant_messages,
tool_calls,
tool_results,
total_tokens,
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
cost,
} => {
let name_display = name
.or_else(|| {
app.session
.as_ref()
.and_then(|s| s.session().session_name())
})
.unwrap_or_else(|| "unnamed".to_string());
let file_display = file_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "in-memory".to_string());
let sid = if session_id.is_empty() {
app.session
.as_ref()
.map(|s| s.session().session_id())
.unwrap_or_default()
} else {
session_id
};
let total_messages = message_count;
let mut info = format!(
"Session Info\n\n\
Name: {name_display}\n\
File: {file_display}\n\
ID: {sid}\n\
\n\
Messages\n\
User: {user_messages}\n\
Assistant: {assistant_messages}\n\
Tool Calls: {tool_calls}\n\
Tool Results: {tool_results}\n\
Total: {total_messages}\n\
\n\
Tokens\n\
Input: {}\n\
Output: {}",
format_number(input_tokens),
format_number(output_tokens),
);
if cache_read_tokens > 0 {
info += &format!("\nCache Read: {}", format_number(cache_read_tokens));
}
if cache_write_tokens > 0 {
info += &format!("\nCache Write: {}", format_number(cache_write_tokens));
}
info += &format!("\nTotal: {}", format_number(total_tokens));
if cost > 0.0 {
info += &format!("\n\nCost\nTotal: {:.4}", cost);
}
if let Some(ref asession) = app.session
&& let Some(file_path) = asession.session().session_file().as_ref()
&& let Some(h) = crate::agent::session::read_session_header(file_path)
&& let Some(ref parent) = h.parent_session
{
info += &format!("\n\nParent: {}", parent);
}
chat_info(app, info.clone());
}
CommandResult::OpenSessionSelector => {
use crate::agent::SessionRepo;
let repo = crate::agent::DefaultSessionRepo::new();
let sessions = repo.list_all(None);
if sessions.is_empty() {
let msg = "No sessions found.".to_string();
chat_info(app, msg.clone());
} else {
let mut info = format!("Available Sessions ({} total)\n\n", sessions.len());
for (i, s) in sessions.iter().take(20).enumerate() {
let name = s.name.as_deref().unwrap_or("unnamed");
let cwd_short = s.cwd.rsplit('/').next().unwrap_or(&s.cwd);
info += &format!(
"{}. {} [{}] {} msgs\n {}\n\n",
i + 1,
name,
fmt_time_short(&s.created),
s.message_count,
cwd_short,
);
}
if sessions.len() > 20 {
info += &format!("... and {} more sessions\n", sessions.len() - 20);
}
info += "Use /resume to open the interactive picker";
chat_info(app, info.clone());
}
}
CommandResult::SessionNamed { name } => {
if let Some(ref mut s) = app.session {
s.session_mut().append_session_info(&name);
}
let stored_name = app
.session
.as_ref()
.and_then(|s| s.session().session_name());
if let Some(ref stored) = stored_name
&& stored != &name
{
chat_info(
app,
format!("Session name normalized from {:?} to {:?}", name, stored),
);
}
chat_info(
app,
format!(
"Session name set: {}",
stored_name.as_deref().unwrap_or(&name)
),
);
app.status_text = Some(format!(
"Session name set: {}",
stored_name.as_deref().unwrap_or(&name)
));
app.update_session_info();
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
}
CommandResult::OpenModelSelector => {
app.pending_command_result = Some(result);
}
CommandResult::OpenSettings => {
app.pending_command_result = Some(result);
}
CommandResult::ScopedModels => {
app.pending_command_result = Some(result);
}
CommandResult::ExportSession { path } => {
let result = (|| -> Result<PathBuf, String> {
let agent_session = app.session.as_ref().ok_or("No active session")?;
let session = agent_session.session();
let system_prompt = Some(app.system_prompt.as_str());
let theme = crate::agent::ui::theme::current_theme();
let theme_name = Some(theme.name.as_str());
let output_path = if path.as_ref().is_some_and(|p| p.ends_with(".jsonl")) {
export::export_to_jsonl(session, &app.cwd, path.as_deref())
.map_err(|e| format!("Export failed: {}", e))?
} else {
export::export_to_html(
session,
system_prompt,
&app.cwd,
path.as_deref(),
theme_name,
)
.map_err(|e| format!("Export failed: {}", e))?
};
Ok(output_path)
})();
match result {
Ok(path) => {
let display = crate::builtin::shorten_path(path.to_string_lossy().as_ref());
chat_info(app, format!("✓ Session exported to: {}", display));
}
Err(msg) => {
chat_info(app, format!("✗ {}", msg));
}
}
}
result @ CommandResult::ImportSession { .. } => {
app.pending_command_result = Some(result);
}
CommandResult::ShareSession => {
let msg = "Share session - not yet implemented.".to_string();
chat_info(app, msg.clone());
}
CommandResult::CopyLastMessage => {
let text = app.session.as_ref().and_then(|s| {
let entries = s.session().get_entries();
entries.iter().rev().find_map(|entry| {
if let SessionEntry::Message(m) = entry
&& matches!(
&m.message,
yoagent::types::AgentMessage::Llm(
yoagent::types::Message::Assistant {
stop_reason, ..
},
) if *stop_reason != yoagent::types::StopReason::Aborted
|| !crate::agent::types::message_text(&m.message)
.trim()
.is_empty()
)
{
let text = crate::agent::types::message_text(&m.message);
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
})
});
let text = match text {
Some(t) => t,
None => {
chat_info(app, "No agent messages to copy yet.");
return;
}
};
copy_to_clipboard(&text);
chat_info(app, "Copied last agent message to clipboard");
}
CommandResult::ShowChangelog => {
let msg = "Changelog - not yet implemented.".to_string();
chat_info(app, msg.clone());
}
CommandResult::ForkSession { message_id } => {
let source_path = app
.session
.as_ref()
.and_then(|s| s.session().session_file());
let session_dir = app.session.as_ref().map(|s| s.session_dir().to_path_buf());
let cwd = app.cwd.clone();
match (source_path, session_dir) {
(Some(ref source), Some(ref target_dir)) => {
match crate::agent::session::fork_session(
source,
target_dir,
message_id.as_deref(),
None,
) {
Ok(new_id) => {
let dir_entries = std::fs::read_dir(target_dir).ok();
let new_path = dir_entries.and_then(|entries| {
entries
.flatten()
.find(|e| {
let filename = e.file_name();
filename.to_string_lossy().contains(&new_id)
})
.map(|e| e.path())
});
match new_path {
Some(ref path) => {
let new_session =
crate::agent::AgentSession::open(path, None, Some(&cwd));
app.switch_to_session(new_session);
let styled = app.theme.fg(
"accent",
&format!("✓ Forked session: {}", path.display()),
);
chat_add(
app,
std::boxed::Box::new(Text::new(styled, 1, 1, None)),
);
}
None => {
let msg =
format!("Fork created but new file not found: {}", new_id);
chat_info(app, msg);
}
}
}
Err(e) => {
let msg = format!("Fork failed: {}", e);
chat_info(app, msg.clone());
}
}
}
_ => {
let msg = "No active session to fork".to_string();
chat_info(app, msg.clone());
}
}
}
CommandResult::CloneSession => {
let msg = "Clone session - not yet implemented.".to_string();
chat_info(app, msg.clone());
}
CommandResult::SessionTree => {
app.pending_command_result = Some(result);
}
CommandResult::TrustDecision { decision } => {
let msg = format!("Trust decision '{}' saved.", decision);
chat_info(app, msg.clone());
}
CommandResult::Login {
ref provider,
ref api_key,
} => {
if let (Some(provider), Some(key)) = (provider, api_key) {
handle_login(app, provider, Some(key));
} else {
app.pending_command_result = Some(result);
}
}
CommandResult::Logout { ref provider } => {
if let Some(p) = provider {
handle_logout(app, Some(p));
} else {
app.pending_command_result = Some(result);
}
}
CommandResult::CompactSession(custom_instructions) => {
if app.is_streaming {
interrupt_streaming(app);
}
app.pending_compact = Some(custom_instructions);
}
}
}
fn find_tool_renderer(
extensions: &[Box<dyn crate::agent::extension::Extension>],
name: &str,
) -> Option<Arc<dyn ToolRenderer>> {
for ext in extensions {
for tool in ext.tools() {
if tool.name() == name {
return tool.renderer;
}
}
}
None
}
fn handle_bang_command(app: &mut App, command: String) {
let cwd = app.cwd.clone();
let tx = app.event_tx.clone();
use yoagent::types::{AgentEvent as YoEvent, Content as YoContent, ToolResult as YoResult};
let renderer = find_tool_renderer(&app.extensions, "bash");
let mut tool = crate::agent::ui::components::ToolExecComponent::new(
"bash",
renderer,
serde_json::json!({"command": command}),
app.cwd.to_string_lossy().to_string(),
"__bang__".to_string(),
);
tool.set_started_at(std::time::Instant::now());
let (invalidate_tx, invalidate_rx) =
crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
app.invalidate_rxs.push(invalidate_rx);
tool.set_invalidate_tx(invalidate_tx);
tool.set_expanded(app.tools_expanded);
let tool = Rc::new(RefCell::new(tool));
app.pending_tools
.insert("__bang__".to_string(), Rc::downgrade(&tool));
chat_add(
app,
std::boxed::Box::new(crate::agent::ui::components::RcToolExec(tool)),
);
app.is_streaming = true;
app.working.start();
app.footer.borrow_mut().set_streaming(true);
app.pending_tool_executions += 1;
let handle = tokio::spawn(async move {
struct Guard<'a> {
tx: &'a mpsc::UnboundedSender<yoagent::types::AgentEvent>,
sent: bool,
}
impl Drop for Guard<'_> {
fn drop(&mut self) {
if !self.sent {
let _ = self.tx.send(YoEvent::AgentEnd { messages: vec![] });
}
}
}
let mut guard = Guard {
tx: &tx,
sent: false,
};
let send_progress = |text: &str| {
let _ = tx.send(YoEvent::ProgressMessage {
tool_call_id: "__bang__".to_string(),
tool_name: "bash".into(),
text: text.to_string(),
});
};
let mut child = match tokio::process::Command::new("sh")
.arg("-c")
.arg(&command)
.current_dir(&cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = tx.send(YoEvent::ToolExecutionEnd {
tool_call_id: "__bang__".to_string(),
tool_name: "bash".into(),
result: YoResult {
content: vec![YoContent::Text {
text: format!("Failed to execute: {:#}", e),
}],
details: serde_json::Value::Null,
},
is_error: true,
});
guard.sent = true;
let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
return;
}
};
let mut all_output = String::new();
use tokio::io::AsyncReadExt;
let mut stdio = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let mut buf1 = [0u8; 4096];
let mut buf2 = [0u8; 4096];
let mut stdout_done = false;
let mut stderr_done = false;
loop {
tokio::select! {
result = stdio.read(&mut buf1), if !stdout_done => {
match result {
Ok(0) => stdout_done = true,
Ok(n) => {
if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
all_output.push_str(text);
send_progress(text);
}
}
Err(_) => stdout_done = true,
}
}
result = stderr.read(&mut buf2), if !stderr_done => {
match result {
Ok(0) => stderr_done = true,
Ok(n) => {
if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
all_output.push_str(text);
send_progress(text);
}
}
Err(_) => stderr_done = true,
}
}
}
if stdout_done && stderr_done {
break;
}
}
let status = child.wait().await;
let is_error = match &status {
Ok(s) => !s.success(),
Err(_) => true,
};
let result = if all_output.trim().is_empty() {
"(no output)".to_string()
} else {
all_output.trim().to_string()
};
let _ = tx.send(YoEvent::ToolExecutionEnd {
tool_call_id: "__bang__".to_string(),
tool_name: "bash".into(),
result: YoResult {
content: vec![YoContent::Text { text: result }],
details: serde_json::Value::Null,
},
is_error,
});
guard.sent = true;
let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
});
app.bash_abort_handle = Some(handle.abort_handle());
}
pub fn rebuild_chat_from_messages(
chat: &mut crate::tui::Container,
messages: &[yoagent::types::AgentMessage],
cwd: &str,
hide_thinking: bool,
_collapse_tool_output: bool,
extensions: &[Box<dyn crate::agent::extension::Extension>],
) {
chat.clear();
use std::collections::HashMap;
let mut pending_tool_components: HashMap<
String,
Rc<RefCell<crate::agent::ui::components::ToolExecComponent>>,
> = HashMap::new();
for msg in messages {
if crate::agent::types::message_is_user(msg) {
let text = crate::agent::types::message_text(msg);
if text.is_empty() {
continue;
}
if !chat.children().is_empty() {
chat.add_child(std::boxed::Box::new(Spacer::new(1)));
}
chat.add_child(std::boxed::Box::new(
crate::agent::ui::components::UserMessageComponent::new(text),
));
} else if crate::agent::types::message_is_assistant(msg) {
let text = crate::agent::types::message_text(msg);
if let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
content,
..
}) = msg
{
let tcs = crate::agent::types::content_tool_calls(content);
if !tcs.is_empty() {
if !text.trim().is_empty() {
add_assistant_message(chat, &text, hide_thinking);
}
for (id, name, args) in &tcs {
let renderer = find_tool_renderer(extensions, name);
let tool = crate::agent::ui::components::ToolExecComponent::new(
name,
renderer,
args.clone(),
cwd.to_string(),
id.clone(),
);
let tool = Rc::new(RefCell::new(tool));
chat.add_child(std::boxed::Box::new(
crate::agent::ui::components::RcToolExec(tool.clone()),
));
pending_tool_components.insert(id.clone(), tool);
}
} else if !text.trim().is_empty() {
add_assistant_message(chat, &text, hide_thinking);
}
}
} else if crate::agent::types::message_is_tool_result(msg) {
let is_error = crate::agent::types::message_is_error(msg);
let text = crate::agent::types::message_text(msg);
if let Some(tc_id) = crate::agent::types::message_tool_call_id(msg)
&& let Some(tool) = pending_tool_components.remove(tc_id)
{
let clean = text
.strip_prefix("✓ ")
.or_else(|| text.strip_prefix("✗ "))
.unwrap_or(&text);
let mut tool = tool.borrow_mut();
tool.set_result_with_details(clean, is_error, None);
}
} else if crate::agent::types::message_is_extension(msg) {
if let Some(text) = crate::agent::types::message_extension_text(msg) {
if !chat.children().is_empty() {
chat.add_child(std::boxed::Box::new(Spacer::new(1)));
}
chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(text)));
}
}
}
}
pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
let mut chat = app.chat_container.borrow_mut();
if !chat.children().is_empty() {
chat.add_child(std::boxed::Box::new(Spacer::new(1)));
}
chat.add_child(component);
}
pub fn chat_info(app: &mut App, msg: impl Into<String>) {
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.into())),
);
}
fn add_assistant_message(chat: &mut crate::tui::Container, text: &str, hide_thinking: bool) {
if !chat.children().is_empty() {
chat.add_child(std::boxed::Box::new(Spacer::new(1)));
}
let mut asst = crate::agent::ui::components::AssistantMessageComponent::new(text);
if hide_thinking {
asst.set_hide_thinking(true);
}
chat.add_child(std::boxed::Box::new(asst));
}
fn show_summarization_prompt(app: &mut App, tui: &mut TUI, _entry_id: &str) {
use crate::tui::Component;
use crate::tui::keybindings::{
ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM,
ACTION_SELECT_DOWN, ACTION_SELECT_UP, get_keybindings,
};
use crossterm::event::KeyEvent;
use std::cell::RefCell;
use std::rc::Rc;
struct SummarizationPrompt {
selected_index: usize,
items: [&'static str; 3],
signal: Rc<RefCell<Option<OverlayResult>>>,
entry_id: String,
edit_mode: bool,
edit_text: String,
}
impl Component for SummarizationPrompt {
fn render(&mut self, width: usize) -> Vec<String> {
let theme = crate::agent::ui::theme::current_theme();
let mut lines = Vec::new();
lines.push(theme.fg("muted", &"─".repeat(width.saturating_sub(2))));
lines.push(String::new());
lines.push(format!(" {}", theme.bold("Summarize branch?")));
lines.push(String::new());
if self.edit_mode {
lines.push(format!(
" {}",
theme.fg("muted", "Custom summarization instructions (Enter to submit, Shift/Ctrl+Enter for newline):")
));
lines.push(String::new());
if self.edit_text.is_empty() {
lines.push(format!(
" {}",
theme.fg("muted", "<type here, Enter for newline>")
));
} else {
for line in self.edit_text.lines() {
lines.push(format!(" {}", line));
}
}
lines.push(String::new());
lines.push(format!(
" {}",
theme.fg(
"muted",
"Enter: submit \u{00b7} Shift/Ctrl+Enter: newline \u{00b7} Esc: back"
)
));
} else {
for (i, item) in self.items.iter().enumerate() {
let prefix = if i == self.selected_index {
theme.fg("accent", "\u{203a} ")
} else {
" ".to_string()
};
let text = if i == self.selected_index {
theme.fg("accent", item)
} else {
theme.text_color(item)
};
lines.push(format!("{}{}", prefix, text));
}
lines.push(String::new());
lines.push(theme.fg(
"muted",
" \u{2191}/\u{2193} navigate \u{00b7} Enter select \u{00b7} Esc back to tree",
));
}
lines
}
fn handle_input(&mut self, key: &KeyEvent) -> bool {
let kb = get_keybindings();
if self.edit_mode {
if key.code == crossterm::event::KeyCode::Esc {
self.edit_mode = false;
return true;
}
if key.code == crossterm::event::KeyCode::Enter
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT)
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
let instructions = self.edit_text.trim().to_string();
let ci = if instructions.is_empty() {
None
} else {
Some(instructions)
};
*self.signal.borrow_mut() = Some(OverlayResult::TreeSummarizeChoice {
entry_id: self.entry_id.clone(),
summarize: true,
custom_instructions: ci,
});
return true;
}
if (key.code == crossterm::event::KeyCode::Enter
&& (key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT)
|| key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)))
|| (key.code == crossterm::event::KeyCode::Char('j')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL))
{
self.edit_text.push('\n');
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
self.edit_text.pop();
return true;
}
if let crossterm::event::KeyCode::Char(c) = key.code
&& !c.is_control()
{
self.edit_text.push(c);
return true;
}
return true;
}
if kb.matches(key, ACTION_SELECT_UP) {
self.selected_index = if self.selected_index == 0 {
self.items.len() - 1
} else {
self.selected_index - 1
};
return true;
}
if kb.matches(key, ACTION_SELECT_DOWN) {
self.selected_index = if self.selected_index >= self.items.len() - 1 {
0
} else {
self.selected_index + 1
};
return true;
}
if kb.matches(key, ACTION_SELECT_CONFIRM) {
match self.selected_index {
0 => {
*self.signal.borrow_mut() = Some(OverlayResult::TreeSummarizeChoice {
entry_id: self.entry_id.clone(),
summarize: false,
custom_instructions: None,
});
}
1 => {
*self.signal.borrow_mut() = Some(OverlayResult::TreeSummarizeChoice {
entry_id: self.entry_id.clone(),
summarize: true,
custom_instructions: None,
});
}
2 => {
self.edit_mode = true;
self.edit_text.clear();
return true;
}
_ => {}
}
return true;
}
if kb.matches(key, ACTION_SELECT_CANCEL) {
*self.signal.borrow_mut() = Some(OverlayResult::TreeReopen(self.entry_id.clone()));
return true;
}
false
}
fn invalidate(&mut self) {}
}
let entry_id = _entry_id.to_string();
let prompt = SummarizationPrompt {
selected_index: 0,
items: ["No summary", "Summarize", "Summarize with custom prompt"],
signal: app.overlay_result_signal.clone(),
entry_id,
edit_mode: false,
edit_text: String::new(),
};
tui.show_top_overlay(Box::new(prompt));
}
fn show_status(app: &mut App, message: String) {
let mut chat = app.chat_container.borrow_mut();
if let Some(prev_len) = app.last_status_len
&& chat.len() == prev_len
&& prev_len >= 2
{
chat.pop_child(); chat.pop_child(); }
app.last_status_len = None;
drop(chat);
let mut chat = app.chat_container.borrow_mut();
if !chat.children().is_empty() {
chat.add_child(std::boxed::Box::new(Spacer::new(1)));
}
chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(message)));
app.last_status_len = Some(chat.len());
}
fn extract_text_content(content: &[yoagent::types::Content]) -> String {
content
.iter()
.filter_map(|c| {
if let yoagent::types::Content::Text { text } = c {
Some(text.clone())
} else {
None
}
})
.collect::<Vec<_>>()
.join("")
}
fn copy_to_clipboard(text: &str) -> bool {
use std::io::Write;
let mut copied = false;
if !copied
&& std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()
.and_then(|mut child| {
let _ = child.stdin.take().map(|mut stdin| {
let _ = stdin.write_all(text.as_bytes());
});
child.wait().ok()
})
.is_some_and(|s| s.success())
{
copied = true;
}
if !copied
&& std::process::Command::new("clip")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()
.and_then(|mut child| {
let _ = child.stdin.take().map(|mut stdin| {
let _ = stdin.write_all(text.as_bytes());
});
child.wait().ok()
})
.is_some_and(|s| s.success())
{
copied = true;
}
if !copied
&& std::env::var("TERMUX_VERSION").is_ok()
&& let Ok(mut child) = std::process::Command::new("termux-clipboard-set")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
let _ = child.stdin.take().map(|mut stdin| {
let _ = stdin.write_all(text.as_bytes());
});
copied = child.wait().ok().is_some_and(|s| s.success());
}
if !copied
&& std::env::var("WAYLAND_DISPLAY").is_ok()
&& std::process::Command::new("which")
.arg("wl-copy")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok()
.is_some_and(|s| s.success())
&& let Ok(mut child) = std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
let _ = child.stdin.take().map(|mut stdin| {
let _ = stdin.write_all(text.as_bytes());
});
copied = true;
}
if !copied
&& std::process::Command::new("xclip")
.arg("-selection")
.arg("clipboard")
.arg("-i")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()
.and_then(|mut child| {
let _ = child.stdin.take().map(|mut stdin| {
let _ = stdin.write_all(text.as_bytes());
});
child.wait().ok()
})
.is_some_and(|s| s.success())
{
copied = true;
}
if !copied
&& std::process::Command::new("xsel")
.arg("--clipboard")
.arg("--input")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()
.and_then(|mut child| {
let _ = child.stdin.take().map(|mut stdin| {
let _ = stdin.write_all(text.as_bytes());
});
child.wait().ok()
})
.is_some_and(|s| s.success())
{
copied = true;
}
let remote = std::env::var("SSH_CONNECTION").is_ok()
|| std::env::var("SSH_CLIENT").is_ok()
|| std::env::var("MOSH_CONNECTION").is_ok();
if remote || !copied {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
if encoded.len() <= 100_000 {
let _ = writeln!(std::io::stdout(), "\x1b]52;c;{}\x07", encoded);
let _ = std::io::stdout().flush();
copied = true;
}
}
copied
}
fn handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
{
let ev = &event;
if let E::MessageEnd { message } = ev {
if crate::agent::types::message_is_user(message)
&& let Some(ref mut s) = app.session
{
s.reset_overflow_recovery();
}
if crate::agent::types::message_error(message).is_none()
&& !crate::agent::types::message_is_system_stop(message)
&& let Some(ref mut s) = app.session
{
s.on_agent_event(ev);
}
}
if let E::ToolExecutionEnd { tool_call_id, .. } = ev
&& tool_call_id != "__bang__"
&& let Some(ref mut s) = app.session
{
s.on_agent_event(ev);
}
if let E::AgentEnd { .. } = ev
&& let Some(ref mut s) = app.session
{
s.on_agent_event(ev);
}
}
use yoagent::types::AgentEvent as E;
match event {
E::AgentStart => {
app.is_streaming = true;
app.working.start();
app.refresh_git_branch();
}
E::TurnStart => {}
E::MessageStart { message } => {
if crate::agent::types::message_is_user(&message) {
let text = crate::agent::types::message_text(&message);
if !text.is_empty() {
chat_add(
app,
std::boxed::Box::new(
crate::agent::ui::components::UserMessageComponent::new(&text),
),
);
}
}
}
E::MessageUpdate { delta, .. } => {
use yoagent::types::StreamDelta;
match delta {
StreamDelta::Text { delta } => {
if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
weak.borrow_mut().append_text(&delta);
} else {
use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
let comp = Rc::new(RefCell::new(
crate::agent::ui::components::AssistantMessageComponent::new(&delta),
));
if app.hide_thinking {
comp.borrow_mut().set_hide_thinking(true);
}
app.streaming_component = Some(Rc::downgrade(&comp));
app.chat_container
.borrow_mut()
.add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
}
}
StreamDelta::Thinking { delta } => {
if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
weak.borrow_mut()
.add_thinking(&delta, app.thinking_level.clone());
} else {
use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
let mut comp =
crate::agent::ui::components::AssistantMessageComponent::new("");
comp.add_thinking(&delta, app.thinking_level.clone());
if app.hide_thinking {
comp.set_hide_thinking(true);
}
let comp = Rc::new(RefCell::new(comp));
app.streaming_component = Some(Rc::downgrade(&comp));
app.chat_container
.borrow_mut()
.add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
}
}
StreamDelta::ToolCallDelta { .. } => {}
}
}
E::ToolExecutionStart {
tool_call_id,
tool_name,
args,
} => {
app.pending_tool_executions += 1;
app.streaming_component = None;
let name = tool_name;
let renderer = find_tool_renderer(&app.extensions, &name);
let started_at = std::time::Instant::now();
let (invalidate_tx, invalidate_rx) =
crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
app.invalidate_rxs.push(invalidate_rx);
let comp: Rc<RefCell<_>> = {
let mut tool = crate::agent::ui::components::ToolExecComponent::new(
&name,
renderer,
args.clone(),
app.cwd.to_string_lossy().to_string(),
tool_call_id.clone(),
);
tool.set_started_at(std::time::Instant::now());
tool.set_invalidate_tx(invalidate_tx);
Rc::new(RefCell::new(tool))
};
comp.borrow_mut().set_expanded(app.tools_expanded);
app.pending_tools
.insert(tool_call_id.clone(), Rc::downgrade(&comp));
app.tool_call_start_times
.insert(tool_call_id.clone(), started_at);
chat_add(
app,
std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
);
}
E::ToolExecutionUpdate {
tool_call_id,
partial_result,
..
} => {
let partial_text = extract_text_content(&partial_result.content);
if !partial_text.is_empty()
&& let Some(weak) = app.pending_tools.get(&tool_call_id)
&& let Some(comp) = weak.upgrade()
{
comp.borrow_mut().append_output(&partial_text);
}
}
E::ToolExecutionEnd {
tool_call_id,
tool_name: _,
result,
is_error,
} => {
app.pending_tool_executions = app.pending_tool_executions.saturating_sub(1);
let content = extract_text_content(&result.content);
if let Some(weak) = app.pending_tools.get(&tool_call_id)
&& let Some(comp) = weak.upgrade()
{
comp.borrow_mut()
.set_result_with_details(&content, is_error, Some(result.details));
app.tool_call_start_times.remove(&tool_call_id);
}
}
E::ProgressMessage {
text, tool_name, ..
} => {
if let Some(weak) = app.pending_tools.get("__bang__")
&& let Some(comp) = weak.upgrade()
{
comp.borrow_mut().append_output(&text);
} else if tool_name.is_empty() {
app.status_text = Some(text.trim().to_string());
}
}
E::TurnEnd { message, .. } => {
app.streaming_component = None;
if let Some(err) = crate::agent::types::message_error(&message) {
chat_info(app, format!("Provider error: {}", err));
}
}
E::AgentEnd { messages } => {
app.streaming_component = None;
app.is_streaming = false;
app.working.stop();
app.footer.borrow_mut().set_streaming(false);
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
app.pending_auto_compact = app.auto_compact;
for msg in messages.iter().rev() {
if let Some(yoagent::types::Message::Assistant {
content,
stop_reason,
error_message,
..
}) = msg.as_llm()
&& stop_reason != &yoagent::types::StopReason::ToolUse
{
if let Some(err) = error_message {
chat_info(app, format!("Provider error: {}", err));
break;
}
let has_visible = content.iter().any(|c| match c {
yoagent::types::Content::Text { text } => !text.trim().is_empty(),
yoagent::types::Content::ToolCall { .. } => true,
_ => false,
});
if !has_visible {
chat_info(
app,
"The agent returned an empty response. \
This can happen when the provider's context \
limit is exceeded or the model declined to \
respond. Try sending a new message."
.to_string(),
);
break;
}
}
}
}
E::MessageEnd { message } => {
if let Some(err) = crate::agent::types::message_error(&message) {
chat_info(app, err.to_string());
let ext = crate::agent::types::extension_message("error", err, true);
if let Some(ref mut s) = app.session {
s.persist_extension_message(&ext);
}
} else if crate::agent::types::message_is_system_stop(&message) {
let text = crate::agent::types::message_text(&message);
chat_info(app, text.clone());
if let Some(ref mut s) = app.session {
let ext = crate::agent::types::extension_message("system_stop", text, true);
s.persist_extension_message(&ext);
}
} else if crate::agent::types::message_is_extension(&message) {
if let Some(text) = crate::agent::types::message_extension_text(&message) {
chat_info(app, text);
}
}
}
E::InputRejected { reason } => {
let msg = format!("Input rejected: {}", reason);
chat_info(app, msg);
}
}
}
fn parse_bang_command(input: &str) -> Option<(String, bool)> {
if let Some(rest) = input.strip_prefix("!!") {
let cmd = rest.trim();
if cmd.is_empty() {
None
} else {
Some((cmd.to_string(), true))
}
} else if let Some(rest) = input.strip_prefix('!') {
let cmd = rest.trim();
if cmd.is_empty() {
None
} else {
Some((cmd.to_string(), false))
}
} else {
None
}
}
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
result.chars().rev().collect()
}
fn fmt_time_short(dt: &chrono::DateTime<chrono::Utc>) -> String {
dt.format("%Y-%m-%d %H:%M").to_string()
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn strip_frontmatter(content: &str) -> String {
let content = content.trim_start();
if !content.starts_with("---") {
return content.to_string();
}
let remaining = &content[3..];
let end = match remaining.find("---") {
Some(pos) => pos,
None => return content.to_string(),
};
let body_start = 3 + end + 3;
content[body_start..].trim().to_string()
}
fn read_skill_body(file_path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(file_path).ok()?;
Some(strip_frontmatter(&content))
}
fn format_skill_invocation(skill: &yoagent::skills::Skill, extra: Option<&str>) -> Option<String> {
let body = read_skill_body(&skill.file_path)?;
let block = format!(
r#"<skill name="{}" location="{}">
References are relative to {}.
{}
</skill>"#,
xml_escape(&skill.name),
xml_escape(&skill.file_path.to_string_lossy()),
xml_escape(&skill.base_dir.to_string_lossy()),
body
);
Some(match extra {
Some(instr) if !instr.is_empty() => format!("{}\n\n{}", block, instr),
_ => block,
})
}
fn expand_skill_command(text: &str, skills: &[yoagent::skills::Skill]) -> String {
if !text.starts_with("/skill:") {
return text.to_string();
}
let rest = &text[7..];
let (skill_name, args) = match rest.find(' ') {
Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
None => (rest, ""),
};
match skills.iter().find(|s| s.name == skill_name) {
Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) })
.unwrap_or_else(|| text.to_string()),
None => text.to_string(),
}
}
pub fn parse_skill_block(text: &str) -> Option<(&str, &str, Option<&str>)> {
let text = text.trim();
let after_open = text.strip_prefix("<skill name=\"")?;
let (name, rest) = after_open.split_once("\" location=\"")?;
let (_location, rest) = rest.split_once("\">\n")?;
let close_tag = "\n</skill>";
let content_end = rest.rfind(close_tag)?;
let body = rest[..content_end].trim();
let after_close = rest[content_end + close_tag.len()..].trim();
let user_message = if after_close.is_empty() {
None
} else {
Some(after_close)
};
Some((name, body, user_message))
}
pub fn format_skill_block_for_display(text: &str) -> Option<String> {
let (name, body, user_message) = parse_skill_block(text)?;
let mut result = String::new();
result.push_str("**[");
result.push_str("skill] ");
result.push_str(name);
result.push_str("**\n\n");
result.push_str(body);
result.push('\n');
if let Some(msg) = user_message {
result.push_str("\n---\n");
result.push_str(msg);
result.push('\n');
}
Some(result)
}