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::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::model_selector::ModelSelector;
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;
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 THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
pub struct AppConfig {
pub model: 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 model_supports_reasoning: bool,
pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
pub api_key: String,
}
pub struct App {
cwd: PathBuf,
model: String,
thinking_level: Option<String>,
system_prompt: String,
theme: RabTheme,
commands: Vec<(String, String)>,
available_models: Vec<String>,
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<()>>,
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>,
extensions: Arc<Vec<Box<dyn Extension>>>,
skills: Vec<yoagent::skills::Skill>,
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>>,
session_picker: Option<crate::agent::ui::components::SessionPicker>,
last_status_len: Option<usize>,
queued_steering_count: usize,
pending_follow_ups: Vec<String>,
}
impl App {
fn new(config: AppConfig, session: AgentSession) -> Self {
let mut agent_session = session;
let mut model_config = yoagent::provider::model::ModelConfig::openai_compat(
"https://opencode.ai/zen/go/v1",
&config.model,
"opencode-go",
yoagent::provider::model::OpenAiCompat::deepseek(),
);
model_config.context_window =
crate::agent::compaction::get_model_context_window(&config.model) as u32;
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_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 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();
editor.set_slash_commands(auto_commands);
let commands: Vec<(String, String)> = config
.extensions
.iter()
.flat_map(|e| e.commands())
.map(|c| (c.name, c.description))
.collect();
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_model(&config.model);
footer.set_model_supports_reasoning(config.model_supports_reasoning);
footer.set_thinking_level(config.thinking_level.clone());
footer.set_context_window(crate::agent::compaction::get_model_context_window(
&config.model,
));
let footer = Rc::new(RefCell::new(footer));
let context = agent_session.session().build_session_context();
let history_messages = context.messages.clone();
let mut resource_parts: Vec<String> = Vec::new();
if !config.context_files.is_empty() {
let ctx = config.context_files.join(", ");
resource_parts.push(format!("Context: {}", ctx));
}
if !config.skills.is_empty() {
let skill_names: Vec<&str> = config.skills.iter().map(|s| s.name.as_str()).collect();
resource_parts.push(format!("Skills: {}", skill_names.join(", ")));
}
let cwd_string = config.cwd.to_string_lossy().to_string();
let chat_container =
std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
{
let mut chat = chat_container.borrow_mut();
if !resource_parts.is_empty() {
chat.add_child(std::boxed::Box::new(
crate::agent::ui::components::InfoMessageComponent::new(
resource_parts.join(" · "),
),
));
}
rebuild_chat_from_messages(
&mut chat,
&history_messages,
&cwd_string,
config.hide_thinking,
config.collapse_tool_output,
&config.extensions,
);
}
let result = Self {
cwd: config.cwd,
model: config.model,
thinking_level: config.thinking_level,
system_prompt: config.system_prompt,
theme,
commands,
available_models: config.available_models,
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,
pending_command_result: 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,
session_info: config.session_info,
api_key: config.api_key,
settings: config.settings,
auto_compact: true,
status_text: None,
header: Rc::new(RefCell::new(
crate::agent::ui::components::HeaderComponent::new(),
)),
session_picker: None,
last_status_len: None,
queued_steering_count: 0,
pending_follow_ups: Vec::new(),
};
result.update_session_info();
if let Some(ref 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;
self.pending_follow_ups.clear();
self.queued_steering_count = 0;
}
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 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(
crate::tui::components::RcRefCellComponent(
app.header.clone() as Rc<RefCell<dyn Component>>,
),
));
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;
}
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.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::OpenSettings => {
chat_add(
&mut app,
std::boxed::Box::new(InfoMessageComponent::new(
"Settings menu - not yet implemented.",
)),
);
}
CommandResult::ScopedModels => {
chat_add(
&mut app,
std::boxed::Box::new(InfoMessageComponent::new(
"Scoped models - not yet implemented.",
)),
);
}
CommandResult::Login { .. } => {
chat_add(
&mut app,
std::boxed::Box::new(InfoMessageComponent::new(
"Login dialog - not yet implemented.",
)),
);
}
_ => {}
}
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 {
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));
}
let mut queued_parts: Vec<String> = Vec::new();
if app.queued_steering_count > 0 {
queued_parts.push(format!("{} steering", app.queued_steering_count));
}
if !app.pending_follow_ups.is_empty() {
queued_parts.push(format!("{} follow-up", app.pending_follow_ups.len()));
}
if !queued_parts.is_empty() {
let line = app.theme.fg_key(
ThemeKey::Dim,
&format!(" 📝 queued: {} ", queued_parts.join(", ")),
);
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() {
tui.pop_overlay();
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;
{
let mut chat = app.chat_container.borrow_mut();
for child in chat.children_mut().iter_mut() {
child.set_hide_thinking(app.hide_thinking);
}
}
if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
weak.borrow_mut().set_hide_thinking(app.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 current = app.thinking_level.as_deref().unwrap_or("off");
let next = match THINKING_LEVELS.iter().position(|&l| l == current) {
Some(pos) => THINKING_LEVELS[(pos + 1) % THINKING_LEVELS.len()],
None => "off",
};
app.thinking_level = Some(next.to_string());
app.footer
.borrow_mut()
.set_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);
}
app.status_text = Some(format!("Thinking level: {}", next));
}
fn handle_model_cycle(app: &mut App, dir: isize) {
let n = app.available_models.len();
if n == 0 {
app.status_text = Some("No models available".into());
return;
}
let current_idx = app.available_models.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,
};
app.model = app.available_models[next_idx].clone();
app.footer.borrow_mut().set_model(&app.model);
app.footer.borrow_mut().set_model_supports_reasoning(true);
if let Some(ref mut agent_session) = app.session {
agent_session.on_model_change("opencode-go", &app.model);
}
app.status_text = Some(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()) {
chat_add(
app,
std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
&trimmed,
)),
);
app.pending_follow_ups.push(trimmed);
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);
app.queued_steering_count = 0;
app.pending_follow_ups.clear();
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 open_model_selector(app: &mut App, tui: &mut TUI) {
let models = app.available_models.clone();
let current = app.model.clone();
let selector = ModelSelector::new(models, ¤t, &app.theme);
tui.show_overlay(Box::new(selector), Default::default());
}
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;
}
if trimmed.starts_with("/skill:") {
let expanded = expand_skill_command(&trimmed, &app.skills);
chat_add(
app,
std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
&expanded,
)),
);
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.queued_steering_count += 1;
app.status_text = Some("Skill 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;
}
chat_add(
app,
std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
&trimmed,
)),
);
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.queued_steering_count += 1;
app.status_text = Some("Steering message sent - interrupting current turn".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);
}
fn build_fresh_agent(
model: &str,
api_key: &str,
system_prompt: &str,
thinking_level: yoagent::types::ThinkingLevel,
messages: Vec<yoagent::types::AgentMessage>,
extensions: &[Box<dyn Extension>],
) -> yoagent::agent::Agent {
let mut mc = yoagent::provider::model::ModelConfig::openai_compat(
"https://opencode.ai/zen/go/v1",
model,
"opencode-go",
yoagent::provider::model::OpenAiCompat::deepseek(),
);
mc.context_window = 1_000_000;
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();
yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider)
.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 agent: &mut yoagent::agent::Agent = match &mut app.agent {
Some(existing) => {
existing
}
None => {
app.agent = Some(build_fresh_agent(
&app.model,
&app.api_key,
&app.system_prompt,
thinking,
msgs,
&app.extensions,
));
app.agent.as_mut().unwrap()
}
};
if let Some(ref mut session) = app.session {
session.on_model_change("opencode-go", &app.model);
session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
}
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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(
"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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::Quit => {
app.should_quit = true;
}
CommandResult::ModelChanged(model) => {
app.model = model.clone();
app.footer.borrow_mut().set_model(&model);
app.status_text = Some(format!("Model: {}", model));
}
CommandResult::ShowHelp => {
app.pending_command_result = Some(result);
}
CommandResult::Reloaded => {
if let Err(e) = app.settings.reload(&app.cwd) {
app.status_text = Some(format!("Failed to reload settings: {}", e));
} else {
if let Some(level) = app.settings.default_thinking_level.clone() {
app.thinking_level = Some(level.clone());
app.footer
.borrow_mut()
.set_thinking_level(Some(level.clone()));
}
app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
{
let mut chat = app.chat_container.borrow_mut();
for child in chat.children_mut().iter_mut() {
child.set_hide_thinking(app.hide_thinking);
}
}
if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
weak.borrow_mut().set_hide_thinking(app.hide_thinking);
}
app.editor.borrow_mut().update_border_color(
app.thinking_level.as_deref(),
&app.theme as &dyn crate::tui::Theme,
);
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(
"Settings, extensions, and keybindings reloaded.".to_string(),
)),
);
}
}
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 msgs = app
.session
.as_ref()
.map(|s| s.session().build_session_context().messages)
.unwrap_or_default();
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 user_messages = msgs
.iter()
.filter(|m| crate::agent::types::message_is_user(m))
.count();
let assistant_messages = msgs
.iter()
.filter(|m| crate::agent::types::message_is_assistant(m))
.count();
let tool_results = msgs
.iter()
.filter(|m| crate::agent::types::message_is_tool_result(m))
.count();
let tool_calls: usize = msgs
.iter()
.map(crate::agent::types::message_tool_call_count)
.sum();
let total_messages = user_messages + assistant_messages + tool_results;
let mut input_tokens: u64 = 0;
let mut output_tokens: u64 = 0;
let mut cache_read_tokens: u64 = 0;
let cost: f64 = 0.0;
for msg in &msgs {
if let Some(usage) = crate::agent::types::message_usage(msg) {
input_tokens += usage.input;
output_tokens += usage.output;
cache_read_tokens += usage.cache_read;
}
}
let total_tokens = input_tokens + output_tokens + cache_read_tokens;
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: {}\n\
Total: {}",
format_number(input_tokens),
format_number(output_tokens),
format_number(total_tokens),
);
if cache_read_tokens > 0 {
info += &format!("\nCache Read: {}", format_number(cache_read_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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(info.clone())),
);
}
}
CommandResult::SessionNamed { name } => {
app.status_text = Some(format!("Session name: {}", name));
if let Some(ref mut s) = app.session {
s.session_mut().append_session_info(&name);
}
app.update_session_info();
if let Some(ref s) = app.session {
app.footer.borrow_mut().refresh_from_session(s.session());
}
}
CommandResult::OpenSettings => {
app.pending_command_result = Some(result);
}
CommandResult::ScopedModels => {
app.pending_command_result = Some(result);
}
CommandResult::ExportSession { path } => {
let msg = if let Some(p) = path {
format!("Export session to {} - not yet implemented.", p)
} else {
"Export session - not yet implemented (defaults to HTML).".to_string()
};
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::ImportSession { path } => {
let msg = format!("Import session from {} - not yet implemented.", path);
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::ShareSession => {
let msg = "Share session - not yet implemented.".to_string();
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::CopyLastMessage => {
let msg = "Copy last agent message to clipboard - not yet implemented.".to_string();
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::ShowChangelog => {
let msg = "Changelog - not yet implemented.".to_string();
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg)),
);
}
}
}
Err(e) => {
let msg = format!("Fork failed: {}", e);
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
}
}
_ => {
let msg = "No active session to fork".to_string();
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
}
}
CommandResult::CloneSession => {
let msg = "Clone session - not yet implemented.".to_string();
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::SessionTree => {
let msg = "Session tree - not yet implemented.".to_string();
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::TrustDecision { decision } => {
let msg = format!("Trust decision '{}' saved.", decision);
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
CommandResult::Login { provider: _ } => {
app.pending_command_result = Some(result);
}
CommandResult::Logout { provider } => {
let prov = provider.as_deref().unwrap_or("all providers");
let msg = format!("Logged out from {} - not yet implemented.", prov);
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
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 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);
let _ = tx.send(YoEvent::ProgressMessage {
tool_call_id: "__bang__".to_string(),
tool_name: "bash".into(),
text: text.to_string(),
});
}
}
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);
let _ = tx.send(YoEvent::ProgressMessage {
tool_call_id: "__bang__".to_string(),
tool_name: "bash".into(),
text: text.to_string(),
});
}
}
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() {
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));
}
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() {
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));
}
}
} 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);
}
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 handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
match &event {
E::MessageEnd { message } => {
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_some()
|| crate::agent::types::message_is_system_stop(message)
{
} else if let Some(ref mut s) = app.session {
s.on_agent_event(&event);
}
}
E::ToolExecutionEnd { tool_call_id, .. } => {
if tool_call_id != "__bang__"
&& let Some(ref mut s) = app.session
{
s.on_agent_event(&event);
}
}
E::AgentEnd { .. } => {
if let Some(ref mut s) = app.session {
s.on_agent_event(&event);
}
}
_ => {}
}
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 { .. } => {}
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: String = partial_result
.content
.iter()
.filter_map(|c| {
if let yoagent::types::Content::Text { text } = c {
Some(text.clone())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
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: String = result
.content
.iter()
.filter_map(|c| {
if let yoagent::types::Content::Text { text } = c {
Some(text.clone())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
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 { .. } => {
app.streaming_component = None;
}
E::AgentEnd { messages } => {
app.streaming_component = None;
app.is_streaming = false;
app.working.stop();
app.footer.borrow_mut().set_streaming(false);
app.queued_steering_count = 0;
if !app.pending_follow_ups.is_empty() {
let follow_text = app.pending_follow_ups.join("\n");
app.pending_follow_ups.clear();
chat_add(
app,
std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
&follow_text,
)),
);
app.pending_submit = Some(follow_text);
}
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
&& error_message.is_none()
{
let is_empty = content.is_empty()
|| content.iter().all(|c| {
matches!(c, yoagent::types::Content::Text { text } if text.trim().is_empty())
});
if is_empty {
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(
"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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(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_add(app, std::boxed::Box::new(InfoMessageComponent::new(text)));
}
}
}
E::InputRejected { reason } => {
let msg = format!("Input rejected: {}", reason);
chat_add(
app,
std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
);
}
}
}
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>) -> String {
let body = read_skill_body(&skill.file_path).unwrap_or_default();
let base = skill.base_dir.to_string_lossy();
let block = format!(
r#"<skill name="{}" location="{}">
References are relative to {}.
{}
</skill>"#,
xml_escape(&skill.name),
xml_escape(&skill.file_path.to_string_lossy()),
base,
body
);
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) }),
None => text.to_string(),
}
}