use crate::{
agent::AgentManager,
command::{CommandId, CommandRegistry, context_items, theme_items, thinking_items},
config::{VERSION, WELCOME_TIPS_VEC},
event::{AppEvent, Event, EventHandler},
load_config::{GlobalTomlConfig, build_provider_config, register_default_tools},
message::{
Message::{self, AgentMessages, ToolCallMsg, UiMessages},
Status, ToolCallState,
},
theme::{DARK_THEME, LIGHT_THEME, Theme},
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
use oy_agent::{
CommanderAgent, CreateSubAgentTool, Orchestrator, SkillSummary, TokenUsage,
agent::{PromptKind, RequestAgent},
format_token_count,
infrastructure::{agents::main_agent::MainAgent, persistence, tools::ToolRegistry},
oy_ai::{ChatMessage, OpenCodeGoProvider, Role},
};
use ratatui::DefaultTerminal;
use std::path::PathBuf;
use std::{
cell::Cell,
collections::{HashMap, VecDeque},
sync::Arc,
time::Instant,
};
use uuid::Uuid;
const MAX_POPUP_ROWS: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AgentType {
MainAgent,
CommanderAgent,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
Normal,
RevokeSelect,
CommandSelector {
selected: usize,
scroll_offset: usize,
},
SubMenu {
title: String,
items: Vec<(String, String)>,
selected: usize,
scroll_offset: usize,
},
ModelForm {
step: usize,
values: [String; 4],
},
}
#[derive(Debug, Clone)]
pub struct SubAgentUiState {
pub agent_type: String,
pub task: String,
pub start_time: Instant,
pub completed: bool,
pub success: bool,
pub summary: String,
pub elapsed_secs: Option<f64>,
}
#[derive(Debug)]
pub struct App {
pub running: bool,
pub messages: VecDeque<Message>,
pub input: String,
pub cursor_pos: usize,
pub cursor_x: Cell<u16>,
pub cursor_y: Cell<u16>,
pub input_width: Cell<u16>,
pub scroll_offset: Cell<u16>,
pub auto_scroll: Cell<bool>,
pub last_chat_width: Cell<u16>,
pub paste_snippets: HashMap<String, String>,
pub paste_counter: usize,
pub events: EventHandler,
pub global_toml_config: Option<GlobalTomlConfig>,
pub main_agent: Option<AgentManager>,
pub commander_agent: Option<AgentManager>,
pub active_agent: AgentType,
pub command_registry: CommandRegistry,
pub app_mode: AppMode,
pub input_title: String,
pub theme: &'static Theme,
pub agent_status: Cell<Status>,
pub tick_counter: Cell<u64>,
pub token_usage: TokenUsage,
pub skills: Vec<SkillSummary>,
pub pending_prompts: Vec<Uuid>,
pub sub_agent_states: Vec<SubAgentUiState>,
pub sub_agent_scroll: Cell<usize>,
pub sub_agent_panel_y: Cell<u16>,
pub session_uuid: Option<Uuid>,
pub user_history: Vec<String>,
pub history_index: Option<usize>,
}
impl App {
#[allow(clippy::cognitive_complexity)]
pub async fn new(session_path: Option<PathBuf>) -> Self {
let mut messages = VecDeque::new();
let global_toml_config = GlobalTomlConfig::load();
let read_claude = global_toml_config
.as_ref()
.and_then(|c| c.read_claude_skills)
.unwrap_or(true);
let skills = oy_agent::domain::skill::discover_skills(read_claude);
let mut main_agent: Option<AgentManager> = None;
let mut commander_agent: Option<AgentManager> = None;
let mut session_loaded = false;
let (merged_response_tx, merged_response_rx) = tokio::sync::mpsc::channel(128);
let shared_session_uuid = if let Some(path) = &session_path {
match persistence::load_session_messages(path) {
Ok((uuid, ref history_msgs)) => {
for msg in history_msgs {
if msg.role != Role::System {
messages.push_back(Message::AgentMessages(msg.clone(), false));
}
}
if let Some(global_toml_config) = &global_toml_config
&& config_is_complete(global_toml_config)
{
let mut agent = start_agent_with_session(
global_toml_config,
uuid,
history_msgs.clone(),
)
.await;
let rx = agent.response_receiver.take();
if let Some(mut rx) = rx {
let tx = merged_response_tx.clone();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if tx.send(msg).await.is_err() {
break;
}
}
});
}
main_agent = Some(agent);
let mut cmd_agent = start_commander_agent_with_session(
global_toml_config,
uuid,
history_msgs.clone(),
)
.await;
let rx = cmd_agent.response_receiver.take();
if let Some(mut rx) = rx {
let tx = merged_response_tx.clone();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if tx.send(msg).await.is_err() {
break;
}
}
});
}
commander_agent = Some(cmd_agent);
}
messages.push_back(Message::UiMessages(
"Session restored. Continue the conversation below.".to_string(),
));
session_loaded = true;
uuid
},
Err(e) => {
messages.push_back(Message::UiMessages(format!(
"Failed to load session: {}. Starting fresh.",
e
)));
Uuid::now_v7()
},
}
} else {
Uuid::now_v7()
};
if let Some(global_toml_config) = &global_toml_config
&& config_is_complete(global_toml_config)
{
if !session_loaded {
let mut agent =
start_main_agent_background(global_toml_config, shared_session_uuid).await;
let rx = agent.response_receiver.take();
if let Some(mut rx) = rx {
let tx = merged_response_tx.clone();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if tx.send(msg).await.is_err() {
break;
}
}
});
}
main_agent = Some(agent);
}
if commander_agent.is_none() {
let mut cmd_agent =
start_commander_agent_background(global_toml_config, shared_session_uuid).await;
let rx = cmd_agent.response_receiver.take();
if let Some(mut rx) = rx {
let tx = merged_response_tx.clone();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if tx.send(msg).await.is_err() {
break;
}
}
});
}
commander_agent = Some(cmd_agent);
}
}
if !session_loaded {
WELCOME_TIPS_VEC.iter().for_each(|tip| {
if tip.eq(&"OY") {
messages.push_back(Message::UiMessages(format!("{} {}", tip, VERSION)));
} else {
messages.push_back(Message::UiMessages(tip.to_string()));
}
});
}
if main_agent.is_none() && commander_agent.is_none() && !session_loaded {
let has_config = global_toml_config.is_some();
if !has_config || !config_is_complete(global_toml_config.as_ref().unwrap()) {
messages.push_back(Message::UiMessages(
"Welcome to OY! Please configure your API settings using /model command or /settings menu.".to_string()
));
}
}
if !skills.is_empty() {
let skill_names: Vec<String> = skills
.iter()
.map(|s| format!("{}/{}", s.folder_name, s.name))
.collect();
messages.push_back(Message::UiMessages(format!(
"[Available Skills] \n{}",
skill_names.join(", ")
)));
}
if let Some(ref agent_manager) = main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetSkills(skills.clone()))
.await;
}
drop(merged_response_tx);
let events = EventHandler::new_with_receiver(merged_response_rx);
let command_registry = CommandRegistry::new();
let theme: &'static Theme = global_toml_config
.as_ref()
.and_then(|c| c.theme.as_deref())
.map(|t| match t {
"dark" => &DARK_THEME,
_ => &LIGHT_THEME,
})
.unwrap_or(&LIGHT_THEME);
let initial_scroll_offset = if session_loaded { u16::MAX } else { 0 };
Self {
running: true,
messages,
input: String::new(),
cursor_pos: 0,
cursor_x: Cell::new(0),
cursor_y: Cell::new(0),
input_width: Cell::new(0),
scroll_offset: Cell::new(initial_scroll_offset),
auto_scroll: Cell::new(true),
last_chat_width: Cell::new(0),
paste_snippets: HashMap::new(),
paste_counter: 0,
events,
global_toml_config,
main_agent,
commander_agent,
active_agent: AgentType::MainAgent,
command_registry,
app_mode: AppMode::Normal,
input_title: String::new(),
theme,
agent_status: Cell::new(Status::Pause),
tick_counter: Cell::new(0),
token_usage: TokenUsage::new(),
skills,
pending_prompts: Vec::new(),
sub_agent_states: Vec::new(),
sub_agent_scroll: Cell::new(0),
sub_agent_panel_y: Cell::new(u16::MAX),
session_uuid: Some(shared_session_uuid),
user_history: Vec::new(),
history_index: None,
}
}
pub async fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> {
while self.running {
terminal.draw(|frame| {
frame.render_widget(&self, frame.area());
frame.set_cursor_position((self.cursor_x.get(), self.cursor_y.get()));
})?;
match self.events.next().await? {
Event::Tick => self.tick(),
Event::Crossterm(event) => match event {
crossterm::event::Event::Key(key_event)
if key_event.kind == crossterm::event::KeyEventKind::Press =>
{
self.handle_key_events(key_event).await?
},
crossterm::event::Event::Paste(text) => {
self.handle_paste(&text);
},
crossterm::event::Event::Mouse(mouse_event) => match mouse_event.kind {
MouseEventKind::ScrollDown => {
let in_sub_area = !self.sub_agent_states.is_empty()
&& mouse_event.row >= self.sub_agent_panel_y.get();
if in_sub_area {
let max = self.sub_agent_states.len().saturating_sub(1);
let current = self.sub_agent_scroll.get();
self.sub_agent_scroll.set((current + 1).min(max));
} else {
self.scroll_offset
.set(self.scroll_offset.get().saturating_add(3));
}
},
MouseEventKind::ScrollUp => {
let in_sub_area = !self.sub_agent_states.is_empty()
&& mouse_event.row >= self.sub_agent_panel_y.get();
if in_sub_area {
let current = self.sub_agent_scroll.get();
self.sub_agent_scroll.set(current.saturating_sub(1));
} else {
self.scroll_offset
.set(self.scroll_offset.get().saturating_sub(3));
self.auto_scroll.set(false);
}
},
_ => {},
},
_ => {},
},
Event::App(app_event) => match app_event {
AppEvent::Quit => self.quit(),
AppEvent::ChatMessage(chat_message) => {
self.handle_chat_message(chat_message).await;
},
AppEvent::TokenUsage(token_usage) => {
self.token_usage = token_usage;
},
AppEvent::AgentError(e) => {
self.insert_before_queued(UiMessages(format!("errors: {}", e)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
},
AppEvent::Pause => {
self.agent_status.set(Status::Pause);
},
AppEvent::Running => {
self.agent_status.set(Status::Running);
},
AppEvent::PromptConsumed { id } => {
self.pending_prompts.retain(|x| *x != id);
self.messages.retain(|msg| match msg {
Message::PromptQueued { id: queued_id, .. } => *queued_id != id,
_ => true,
});
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
},
AppEvent::PromptQueued { id } => {
if !self.pending_prompts.contains(&id) {
self.pending_prompts.push(id);
}
},
},
}
}
Ok(())
}
#[allow(clippy::cognitive_complexity)]
async fn handle_chat_message(&mut self, chat_message: oy_agent::oy_ai::ChatMessage) {
use oy_agent::oy_ai::Role;
if chat_message.role == Role::Assistant
&& let Some(tool_calls) = &chat_message.tool_calls
&& !tool_calls.is_empty()
{
let mut content_msg = chat_message.clone();
content_msg.tool_calls = None;
if content_msg.content.is_some() || content_msg.reasoning_content.is_some() {
self.insert_before_queued(AgentMessages(content_msg, false));
}
for tc in tool_calls {
if tc.function_name == "create_sub_agent" {
let task = tc
.arguments
.get("task")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let agent_type = tc
.arguments
.get("agent_type")
.and_then(|v| v.as_str())
.unwrap_or("sub-agent")
.to_string();
self.sub_agent_states.push(SubAgentUiState {
agent_type,
task,
start_time: Instant::now(),
completed: false,
success: false,
summary: String::new(),
elapsed_secs: None,
});
}
let default_timeout = if tc.function_name == "create_sub_agent" {
900
} else {
150
};
let timeout_secs = tc
.arguments
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(default_timeout);
self.insert_before_queued(ToolCallMsg(ToolCallState {
function_name: tc.function_name.clone(),
arguments: if tc.function_name.eq("Read")
|| tc.function_name.eq("Edit")
|| tc.function_name.eq("Write")
{
tc.arguments
.get("file_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else if tc.function_name.eq("Bash") {
tc.arguments
.get("command")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else if tc.function_name.eq("create_sub_agent") {
tc.arguments
.get("agent_type")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else if tc.function_name.eq("grep") {
tc.arguments
.get("pattern")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else if tc.function_name.eq("uuid") {
tc.arguments
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
},
tool_call_id: tc.id.clone(),
result: None,
start_time: Instant::now(),
end_time: None,
expanded: false,
timeout_secs,
}));
}
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
if chat_message.role == Role::Tool
&& let Some(call_id) = &chat_message.tool_call_id
{
let mut sub_agent_error: Option<String> = None;
for msg in self.messages.iter_mut().rev() {
if let ToolCallMsg(state) = msg
&& state.result.is_none()
&& state.tool_call_id == *call_id
{
if state.function_name == "create_sub_agent" {
let success = chat_message
.content
.as_deref()
.map(|c| {
!c.contains("失败")
&& !c.contains("Internal error")
&& !c.contains("Error:")
})
.unwrap_or(false);
if let Some(last) = self
.sub_agent_states
.iter_mut()
.rev()
.find(|s| !s.completed)
{
last.completed = true;
last.success = success;
last.summary = chat_message.content.clone().unwrap_or_default();
last.elapsed_secs = Some(last.start_time.elapsed().as_secs_f64());
}
if !success {
sub_agent_error = chat_message
.content
.as_deref()
.map(|c| c.lines().next().unwrap_or("unknown error").to_string());
}
}
state.result = Some(chat_message);
state.end_time = Some(Instant::now());
break;
}
}
if let Some(err) = sub_agent_error {
self.insert_before_queued(UiMessages(format!("⚠ Sub-agent error: {}", err)));
}
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
self.insert_before_queued(AgentMessages(chat_message, false));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
pub async fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
if key_event.code == KeyCode::Char('o') && key_event.modifiers == KeyModifiers::CONTROL {
for msg in self.messages.iter_mut().rev() {
match msg {
Message::AgentMessages(_, expanded) => {
*expanded = !*expanded;
break;
},
Message::ToolCallMsg(state) => {
state.expanded = !state.expanded;
break;
},
_ => {},
}
}
return Ok(());
}
match self.app_mode {
AppMode::Normal => self.handle_key_normal(key_event).await,
AppMode::RevokeSelect => self.handle_key_revoke_select(key_event).await,
AppMode::CommandSelector { selected, .. } => {
self.handle_key_command_selector(key_event, selected).await
},
AppMode::ModelForm { .. } => self.handle_key_model_form(key_event).await,
AppMode::SubMenu { .. } => self.handle_key_submenu(key_event).await,
}
}
#[allow(clippy::cognitive_complexity)]
async fn handle_key_normal(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit),
KeyCode::Char('r' | 'R')
if key_event.modifiers == KeyModifiers::CONTROL
&& !self.pending_prompts.is_empty() =>
{
self.app_mode = AppMode::RevokeSelect;
},
KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
if self.input.is_empty() {
self.events.send(AppEvent::Quit)
} else {
self.input.clear();
self.cursor_pos = 0;
self.history_index = None;
self.app_mode = AppMode::Normal;
}
},
KeyCode::Enter if !self.input.is_empty() => {
return self.handle_key_enter(key_event).await;
},
KeyCode::Backspace if self.cursor_pos > 0 && !self.delete_paste_placeholder() => {
let len = self.input[..self.cursor_pos]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.input
.replace_range(self.cursor_pos - len..self.cursor_pos, "");
self.cursor_pos -= len;
if self.input.is_empty() {
self.app_mode = AppMode::Normal;
self.history_index = None;
}
},
KeyCode::Left if self.cursor_pos > 0 => {
let len = self.input[..self.cursor_pos]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.cursor_pos -= len;
},
KeyCode::Right if self.cursor_pos < self.input.len() => {
let len = self.input[self.cursor_pos..]
.chars()
.next()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.cursor_pos += len;
},
KeyCode::Up => {
let width = self.input_width.get() as usize;
if self.input.is_empty() {
self.user_history = self.extract_user_history();
if !self.user_history.is_empty() {
let next_index = match self.history_index {
None => 0,
Some(idx) => (idx + 1).min(self.user_history.len() - 1),
};
self.history_index = Some(next_index);
self.input = self.user_history[next_index].clone();
self.cursor_pos = self.input.len();
}
} else if self.history_index.is_some() {
if let Some(idx) = self.history_index {
let next_idx = (idx + 1).min(self.user_history.len() - 1);
if next_idx != idx {
self.history_index = Some(next_idx);
self.input = self.user_history[next_idx].clone();
self.cursor_pos = self.input.len();
}
}
} else if width > 0 {
self.move_cursor_up(width);
}
},
KeyCode::Down => {
let width = self.input_width.get() as usize;
if let Some(idx) = self.history_index {
if idx == 0 {
self.history_index = None;
self.input.clear();
self.cursor_pos = 0;
} else {
let prev_idx = idx - 1;
self.history_index = Some(prev_idx);
self.input = self.user_history[prev_idx].clone();
self.cursor_pos = self.input.len();
}
} else if width > 0 {
self.move_cursor_down(width);
}
},
KeyCode::Char('v') if key_event.modifiers == KeyModifiers::CONTROL => {
self.paste_from_clipboard();
},
KeyCode::Char('V')
if key_event.modifiers == KeyModifiers::CONTROL | KeyModifiers::SHIFT =>
{
self.paste_from_clipboard();
},
KeyCode::Char('v') if key_event.modifiers == KeyModifiers::ALT => {
self.paste_from_clipboard();
},
KeyCode::Insert if key_event.modifiers == KeyModifiers::SHIFT => {
self.paste_from_clipboard();
},
KeyCode::BackTab => {
self.switch_agent().await;
},
KeyCode::Tab if key_event.modifiers == KeyModifiers::SHIFT => {
self.switch_agent().await;
},
KeyCode::Char(c) => {
self.history_index = None;
self.input.insert(self.cursor_pos, c);
self.cursor_pos += c.len_utf8();
if self.input == "/"
|| (self.input.starts_with('/')
&& self.input.len() > 1
&& !self.command_registry.search(&self.input).is_empty())
{
self.app_mode = AppMode::CommandSelector {
selected: 0,
scroll_offset: 0,
};
}
},
_ => {},
}
Ok(())
}
async fn handle_key_enter(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
self.expand_paste_snippets();
let input = std::mem::take(&mut self.input);
self.cursor_pos = 0;
self.history_index = None;
self.paste_counter = 0;
let kind = if key_event.modifiers == KeyModifiers::ALT {
PromptKind::AltEnter
} else {
PromptKind::Enter
};
if input.starts_with('/') && self.execute_command(&input).await {
} else {
self.route_prompt(&input, kind).await;
}
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
Ok(())
}
async fn route_prompt(&mut self, input: &str, kind: PromptKind) {
let agent = match self.active_agent {
AgentType::MainAgent => &self.main_agent,
AgentType::CommanderAgent => &self.commander_agent,
};
let Some(agent_manager) = agent else {
let msg = if self.active_agent == AgentType::MainAgent {
"MainAgent not initialized. Please use /model to configure your API key and model first."
} else {
"CommanderAgent not initialized."
};
self.insert_before_queued(UiMessages(msg.to_string()));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
};
if self.pending_prompts.len() >= 9 {
self.insert_before_queued(UiMessages(
"Maximum 9 prompts can be queued. Press Ctrl+R then 1..9 to revoke a queued prompt first.".to_string()
));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
let id = Uuid::now_v7();
let _ = agent_manager
.request_sender
.send(RequestAgent::Prompt {
text: input.to_string(),
id,
kind,
})
.await;
self.pending_prompts.push(id);
self.insert_before_queued(Message::PromptQueued {
id,
text: input.to_string(),
});
}
async fn handle_key_command_selector(
&mut self,
key_event: KeyEvent,
selected: usize,
) -> color_eyre::Result<()> {
let matches = self.command_registry.search(&self.input);
let max_idx = matches.len().saturating_sub(1);
match key_event.code {
KeyCode::Up => {
let new_sel = if selected == 0 { max_idx } else { selected - 1 };
let scroll_offset = Self::adjust_scroll(new_sel, matches.len(), MAX_POPUP_ROWS);
self.app_mode = AppMode::CommandSelector {
selected: new_sel,
scroll_offset,
};
},
KeyCode::Down => {
let new_sel = if selected >= max_idx { 0 } else { selected + 1 };
let scroll_offset = Self::adjust_scroll(new_sel, matches.len(), MAX_POPUP_ROWS);
self.app_mode = AppMode::CommandSelector {
selected: new_sel,
scroll_offset,
};
},
KeyCode::Enter if !matches.is_empty() => {
let cmd = matches[selected.min(max_idx)].name;
let input = std::mem::take(&mut self.input);
self.cursor_pos = 0;
if input == cmd || input.starts_with(cmd) {
self.execute_command(cmd).await;
} else {
self.input = cmd.to_string();
self.cursor_pos = self.input.len();
self.execute_command(cmd).await;
}
},
KeyCode::Esc => {
self.app_mode = AppMode::Normal;
self.input.clear();
self.cursor_pos = 0;
},
KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c);
self.cursor_pos += c.len_utf8();
let new_matches = self.command_registry.search(&self.input);
if new_matches.is_empty() {
self.app_mode = AppMode::Normal;
} else {
self.app_mode = AppMode::CommandSelector {
selected: 0,
scroll_offset: 0,
};
}
},
KeyCode::Backspace if self.cursor_pos > 0 => {
let len = self.input[..self.cursor_pos]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.input
.replace_range(self.cursor_pos - len..self.cursor_pos, "");
self.cursor_pos -= len;
if self.input.is_empty() {
self.app_mode = AppMode::Normal;
} else {
let new_matches = self.command_registry.search(&self.input);
if new_matches.is_empty() {
self.app_mode = AppMode::Normal;
} else {
self.app_mode = AppMode::CommandSelector {
selected: 0,
scroll_offset: 0,
};
}
}
},
_ => {},
}
Ok(())
}
#[allow(clippy::cognitive_complexity)]
async fn handle_key_model_form(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
let snapshot = match &self.app_mode {
AppMode::ModelForm { step, values } => (*step, values.clone()),
_ => return Ok(()),
};
let (step, mut values) = snapshot;
match key_event.code {
KeyCode::Esc => {
self.app_mode = AppMode::Normal;
self.input.clear();
self.cursor_pos = 0;
self.input_title.clear();
},
KeyCode::Enter if !self.input.is_empty() => {
values[step] = std::mem::take(&mut self.input);
self.cursor_pos = 0;
let is_single = matches!(
self.input_title.as_str(),
"API Base URL:" | "API Key:" | "Model:" | "Custom Context Capacity (tokens):"
) && step == 0;
if is_single {
let val = values[0].clone();
match self.input_title.as_str() {
"API Base URL:" => self.switch_single_setting("base_url", &val).await,
"API Key:" => self.switch_single_setting("api_key", &val).await,
"Model:" => self.switch_single_setting("model", &val).await,
"Custom Context Capacity (tokens):" => {
if let Ok(n) = val.trim().parse::<u64>() {
self.switch_context_capacity(n).await;
} else {
self.insert_before_queued(UiMessages(format!(
"Invalid context capacity: {}",
val
)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
},
_ => {},
}
self.app_mode = AppMode::Normal;
self.input_title.clear();
} else if step == 3 {
let [url, key, model, ctx] = std::mem::take(&mut values);
self.execute_model_command(url, key, model, ctx).await;
self.app_mode = AppMode::Normal;
self.input_title.clear();
} else {
let new_step = step + 1;
self.app_mode = AppMode::ModelForm {
step: new_step,
values,
};
self.input_title = match new_step {
1 => "API Key:".to_string(),
2 => "Model:".to_string(),
3 => "Context Capacity (tokens, e.g. 200000):".to_string(),
_ => unreachable!(),
};
}
},
KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c);
self.cursor_pos += c.len_utf8();
},
KeyCode::Backspace if self.cursor_pos > 0 => {
let len = self.input[..self.cursor_pos]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.input
.replace_range(self.cursor_pos - len..self.cursor_pos, "");
self.cursor_pos -= len;
},
KeyCode::Left if self.cursor_pos > 0 => {
let len = self.input[..self.cursor_pos]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.cursor_pos -= len;
},
KeyCode::Right if self.cursor_pos < self.input.len() => {
let len = self.input[self.cursor_pos..]
.chars()
.next()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.cursor_pos += len;
},
_ => {},
}
Ok(())
}
fn extract_user_history(&self) -> Vec<String> {
let mut result = Vec::new();
let mut last: Option<String> = None;
for msg in self.messages.iter().rev() {
if let Message::AgentMessages(chat_msg, _) = msg
&& chat_msg.role == Role::User
&& let Some(content) = &chat_msg.content
&& last.as_deref() != Some(content.as_str())
{
result.push(content.clone());
last = Some(content.clone());
}
}
result
}
fn move_cursor_up(&mut self, width: usize) {
let (row, col) = self.cursor_visual_pos(width);
if row == 0 {
return;
}
self.cursor_pos = self.byte_at_visual_pos(row - 1, col, width);
}
fn move_cursor_down(&mut self, width: usize) {
let (row, col) = self.cursor_visual_pos(width);
let total = self.total_visual_lines(width);
if row + 1 >= total {
return;
}
self.cursor_pos = self.byte_at_visual_pos(row + 1, col, width);
}
fn cursor_visual_pos(&self, width: usize) -> (u16, u16) {
if self.input.is_empty() || width == 0 {
return (0, 0);
}
let mut row = 0u16;
let mut col = 0u16;
let mut pending_ws = 0u16;
for (i, ch) in self.input.char_indices() {
if i >= self.cursor_pos {
col += pending_ws;
break;
}
if ch == '\n' {
pending_ws = 0;
row += 1;
col = 0;
} else {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if ch.is_ascii_whitespace() && ch != '\n' {
if col + pending_ws + w > width as u16 {
row += 1;
pending_ws = 0;
col = 0;
} else {
pending_ws += w;
}
} else {
if col + pending_ws + w > width as u16 {
row += 1;
col = w;
} else {
col += pending_ws + w;
}
pending_ws = 0;
}
}
}
col += pending_ws;
if col >= width as u16 {
row += 1;
col = 0;
}
(row, col)
}
fn byte_at_visual_pos(&self, target_row: u16, target_col: u16, width: usize) -> usize {
if self.input.is_empty() || width == 0 {
return 0;
}
let mut row = 0u16;
let mut col = 0u16;
let mut pending_ws = 0u16;
let mut best = 0usize;
for (i, ch) in self.input.char_indices() {
if row > target_row {
break;
}
if ch == '\n' {
if row == target_row {
break;
}
pending_ws = 0;
row += 1;
col = 0;
continue;
}
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if ch.is_ascii_whitespace() && ch != '\n' {
if col + pending_ws + w > width as u16 {
pending_ws = 0;
row += 1;
col = 0;
} else {
pending_ws += w;
}
continue;
}
if col + pending_ws + w > width as u16 {
row += 1;
if row > target_row {
break;
}
col = w;
pending_ws = 0;
if row == target_row {
if target_col == 0 || target_col < w {
best = i;
} else {
best = i + ch.len_utf8();
}
}
} else {
col += pending_ws + w;
pending_ws = 0;
if row == target_row {
if col == target_col || (target_col > col - w && target_col < col) {
return i;
}
best = i + ch.len_utf8();
}
}
}
if row < target_row {
self.input.len()
} else {
best
}
}
pub(crate) fn total_visual_lines(&self, width: usize) -> u16 {
if self.input.is_empty() || width == 0 {
return 1;
}
let mut lines = 1u16;
let mut col = 0u16;
let mut pending_ws = 0u16;
for ch in self.input.chars() {
if ch == '\n' {
pending_ws = 0;
lines += 1;
col = 0;
} else {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if ch.is_ascii_whitespace() && ch != '\n' {
if col + pending_ws + w > width as u16 {
pending_ws = 0;
lines += 1;
col = 0;
} else {
pending_ws += w;
}
} else {
if col + pending_ws + w > width as u16 {
lines += 1;
col = w;
} else {
col += pending_ws + w;
}
pending_ws = 0;
}
}
}
lines
}
fn handle_paste(&mut self, raw: &str) {
let mut text = raw.to_string();
if text.starts_with('\u{FEFF}') {
text = text[3..].to_string();
}
text = text.replace("\r\n", "\n").replace('\r', "\n");
let text = text.trim_end_matches('\n').to_string();
if text.is_empty() {
return;
}
let line_count = text.lines().count();
if line_count >= 2 {
self.paste_counter += 1;
let snippet_id = format!("paste #{}", self.paste_counter);
let placeholder = format!("[{} +{} lines]", snippet_id, line_count);
self.input.insert_str(self.cursor_pos, &placeholder);
self.cursor_pos += placeholder.len();
self.paste_snippets.insert(snippet_id, text);
} else {
self.input.insert_str(self.cursor_pos, &text);
self.cursor_pos += text.len();
}
}
fn paste_from_clipboard(&mut self) {
let output = match std::process::Command::new("pbpaste").output() {
Ok(o) => o,
Err(_) => return,
};
if !output.status.success() {
return;
}
let text = match String::from_utf8(output.stdout) {
Ok(t) => t,
Err(_) => return,
};
self.handle_paste(&text);
}
fn delete_paste_placeholder(&mut self) -> bool {
if self.cursor_pos == 0 {
return false;
}
let search_from = self.cursor_pos.saturating_sub(256);
let before = &self.input[..self.cursor_pos];
if !before.ends_with(']') {
return false;
}
if let Some(rel) = before.rfind("[paste #")
&& rel >= search_from
{
let placeholder = &self.input[rel..self.cursor_pos];
if placeholder.len() > 14 && placeholder.ends_with(" lines]") {
let inner = &placeholder[1..placeholder.len() - 1];
let parts: Vec<&str> = inner.splitn(3, ' ').collect();
if parts.len() == 3 && parts[0] == "paste" {
let id = parts[1].to_string();
self.input.replace_range(rel..self.cursor_pos, "");
self.cursor_pos = rel;
self.paste_snippets.remove(&id);
return true;
}
}
}
false
}
async fn handle_key_submenu(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
let snapshot = match &self.app_mode {
AppMode::SubMenu {
title,
items,
selected,
..
} => (title.clone(), items.clone(), *selected),
_ => return Ok(()),
};
let (title, items, selected) = snapshot;
let max_idx = items.len().saturating_sub(1);
match key_event.code {
KeyCode::Up => {
let new_sel = if selected == 0 { max_idx } else { selected - 1 };
let new_scroll = Self::adjust_scroll(new_sel, items.len(), MAX_POPUP_ROWS);
self.app_mode = AppMode::SubMenu {
title,
items,
selected: new_sel,
scroll_offset: new_scroll,
};
},
KeyCode::Down => {
let new_sel = if selected >= max_idx { 0 } else { selected + 1 };
let new_scroll = Self::adjust_scroll(new_sel, items.len(), MAX_POPUP_ROWS);
self.app_mode = AppMode::SubMenu {
title,
items,
selected: new_sel,
scroll_offset: new_scroll,
};
},
KeyCode::Enter if !items.is_empty() => {
let item = &items[selected.min(max_idx)];
self.execute_submenu_item(&title, &item.0).await;
},
KeyCode::Esc => {
self.app_mode = AppMode::Normal;
self.input.clear();
self.cursor_pos = 0;
},
_ => {},
}
Ok(())
}
async fn execute_submenu_item(&mut self, parent_title: &str, item_name: &str) {
match parent_title {
"/settings" if item_name == "/theme" => {
let items: Vec<(String, String)> = theme_items()
.iter()
.map(|c| (c.name.to_string(), c.description.to_string()))
.collect();
self.app_mode = AppMode::SubMenu {
title: format!("{} {}", parent_title, item_name),
items,
selected: 0,
scroll_offset: 0,
};
},
"/settings" if item_name == "/thinking" => {
let items: Vec<(String, String)> = thinking_items()
.iter()
.map(|c| (c.name.to_string(), c.description.to_string()))
.collect();
self.app_mode = AppMode::SubMenu {
title: format!("{} {}", parent_title, item_name),
items,
selected: 0,
scroll_offset: 0,
};
},
"/settings" if item_name == "/context" => {
let items: Vec<(String, String)> = context_items()
.iter()
.map(|c| (c.name.to_string(), c.description.to_string()))
.collect();
self.app_mode = AppMode::SubMenu {
title: format!("{} {}", parent_title, item_name),
items,
selected: 0,
scroll_offset: 0,
};
},
_ => {
let matched_id = theme_items()
.iter()
.chain(thinking_items().iter())
.chain(context_items().iter())
.chain(
self.command_registry
.commands
.iter()
.flat_map(|c| c.children.iter()),
)
.find(|ci| ci.name == item_name)
.map(|ci| ci.id);
match matched_id {
Some(CommandId::ThemeLight) => self.switch_theme("light"),
Some(CommandId::ThemeDark) => self.switch_theme("dark"),
Some(CommandId::SetBaseUrl) => {
self.input_title = "API Base URL:".to_string();
self.app_mode = AppMode::ModelForm {
step: 0,
values: [String::new(), String::new(), String::new(), String::new()],
};
return;
},
Some(CommandId::SetApiKey) => {
self.input_title = "API Key:".to_string();
self.app_mode = AppMode::ModelForm {
step: 0,
values: [String::new(), String::new(), String::new(), String::new()],
};
return;
},
Some(CommandId::SetModel) => {
self.input_title = "Model:".to_string();
self.app_mode = AppMode::ModelForm {
step: 0,
values: [String::new(), String::new(), String::new(), String::new()],
};
return;
},
Some(CommandId::ReadClaudeSkills) => {
self.switch_claude_skills().await;
},
Some(id)
if matches!(
id,
CommandId::ThinkingNone
| CommandId::ThinkingLow
| CommandId::ThinkingMedium
| CommandId::ThinkingHigh
| CommandId::ThinkingXhigh
) =>
{
let effort = match id {
CommandId::ThinkingNone => "none",
CommandId::ThinkingLow => "low",
CommandId::ThinkingMedium => "medium",
CommandId::ThinkingHigh => "high",
CommandId::ThinkingXhigh => "xhigh",
_ => unreachable!(),
};
self.switch_reasoning_effort(effort).await;
},
Some(id)
if matches!(
id,
CommandId::ContextSize32k
| CommandId::ContextSize64k
| CommandId::ContextSize128k
| CommandId::ContextSize200k
| CommandId::ContextSize512k
| CommandId::ContextSize1M
| CommandId::ContextSizeCustom
) =>
{
let capacity = match id {
CommandId::ContextSize32k => 32_768,
CommandId::ContextSize64k => 65_536,
CommandId::ContextSize128k => 131_072,
CommandId::ContextSize200k => 200_000,
CommandId::ContextSize512k => 524_288,
CommandId::ContextSize1M => 1_048_576,
CommandId::ContextSizeCustom => 0, _ => unreachable!(),
};
if id == CommandId::ContextSizeCustom {
self.input_title = "Custom Context Capacity (tokens):".to_string();
self.app_mode = AppMode::ModelForm {
step: 0,
values: [
String::new(),
String::new(),
String::new(),
String::new(),
],
};
return;
} else {
self.switch_context_capacity(capacity).await;
}
},
_ => {
self.insert_before_queued(UiMessages(format!(
"Unknown submenu item: {}",
item_name
)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
},
}
self.app_mode = AppMode::Normal;
self.input.clear();
self.cursor_pos = 0;
},
}
}
async fn execute_command(&mut self, input: &str) -> bool {
self.input.clear();
self.cursor_pos = 0;
let trimmed = input.trim();
if let Some(cmd) = self
.command_registry
.commands
.iter()
.find(|c| c.name == trimmed)
{
if !cmd.children.is_empty() {
let items: Vec<(String, String)> = cmd
.children
.iter()
.map(|c| (c.name.to_string(), c.description.to_string()))
.collect();
let title = cmd.name.to_string();
self.app_mode = AppMode::SubMenu {
title,
items,
selected: 0,
scroll_offset: 0,
};
} else {
self.input_title = "API Base URL (step 1/4):".to_string();
self.app_mode = AppMode::ModelForm {
step: 0,
values: [String::new(), String::new(), String::new(), String::new()],
};
}
return true;
}
false
}
fn switch_theme(&mut self, name: &str) {
self.theme = match name {
"light" => &LIGHT_THEME,
_ => &DARK_THEME,
};
let config = GlobalTomlConfig {
base_url: None,
api_key: None,
model: None,
theme: Some(name.to_string()),
reasoning_effort: None,
context_capacity: None,
read_claude_skills: None,
};
let _ = config.save();
self.insert_before_queued(UiMessages(format!("Switched to {} theme", self.theme.name)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
async fn switch_reasoning_effort(&mut self, effort: &str) {
let config = GlobalTomlConfig {
base_url: None,
api_key: None,
model: None,
theme: None,
reasoning_effort: Some(effort.to_string()),
context_capacity: None,
read_claude_skills: None,
};
if let Err(e) = config.save() {
self.insert_before_queued(UiMessages(format!("Failed to save config: {}", e)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
if let Some(ref mut global_config) = self.global_toml_config {
global_config.reasoning_effort = Some(effort.to_string());
}
if let Some(ref global_config) = self.global_toml_config
&& config_is_complete(global_config)
{
let ai_config = build_provider_config(global_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config);
if let Some(agent_manager) = &self.main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetProvider(Box::new(provider)))
.await;
}
}
self.insert_before_queued(UiMessages(format!(
"Switched reasoning effort to: {}",
effort
)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
async fn switch_context_capacity(&mut self, capacity: u64) {
let config = GlobalTomlConfig {
base_url: None,
api_key: None,
model: None,
theme: None,
reasoning_effort: None,
context_capacity: Some(capacity),
read_claude_skills: None,
};
if let Err(e) = config.save() {
self.insert_before_queued(UiMessages(format!("Failed to save config: {}", e)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
if let Some(ref mut global_config) = self.global_toml_config {
global_config.context_capacity = Some(capacity);
}
if let Some(ref global_config) = self.global_toml_config
&& config_is_complete(global_config)
{
let ai_config = build_provider_config(global_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config);
if let Some(agent_manager) = &self.main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetProvider(Box::new(provider)))
.await;
}
}
self.insert_before_queued(UiMessages(format!(
"Switched context capacity to: {}",
format_token_count(capacity),
)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
async fn switch_single_setting(&mut self, field: &str, value: &str) {
let mut config = GlobalTomlConfig {
base_url: None,
api_key: None,
model: None,
theme: None,
reasoning_effort: None,
context_capacity: None,
read_claude_skills: None,
};
if let Some(ref global) = self.global_toml_config {
config.base_url = global.base_url.clone();
config.api_key = global.api_key.clone();
config.model = global.model.clone();
config.reasoning_effort = global.reasoning_effort.clone();
config.context_capacity = global.context_capacity;
config.theme = global.theme.clone();
}
match field {
"base_url" => config.base_url = Some(value.to_string()),
"api_key" => config.api_key = Some(value.to_string()),
"model" => config.model = Some(value.to_string()),
_ => {},
}
if let Err(e) = config.save() {
self.insert_before_queued(UiMessages(format!("Failed to save config: {}", e)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
self.global_toml_config = Some(config);
let cfg = self.global_toml_config.as_ref().unwrap();
if config_is_complete(cfg) {
let ai_config =
build_provider_config(cfg).expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config);
if let Some(agent_manager) = &self.main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetProvider(Box::new(provider)))
.await;
}
self.insert_before_queued(UiMessages(format!("Updated {} to: {}", field, value)));
} else {
let mut missing = Vec::new();
if cfg.api_key.as_deref().is_none_or(str::is_empty) {
missing.push("api_key");
}
if cfg.base_url.as_deref().is_none_or(str::is_empty) {
missing.push("base_url");
}
if cfg.model.as_deref().is_none_or(str::is_empty) {
missing.push("model");
}
self.insert_before_queued(UiMessages(format!(
"Saved {}. Still need to configure: {} (use /settings submenu or /model command)",
field,
missing.join(", ")
)));
}
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
async fn execute_model_command(
&mut self,
base_url: String,
api_key: String,
model: String,
context_capacity: String,
) {
let ctx_val: Option<u64> = context_capacity.trim().parse().ok();
let config = GlobalTomlConfig {
base_url: Some(base_url.clone()),
api_key: Some(api_key.clone()),
model: Some(model.clone()),
theme: None,
reasoning_effort: None, context_capacity: ctx_val,
read_claude_skills: None,
};
if let Err(e) = config.save() {
self.insert_before_queued(UiMessages(format!("Failed to save config: {}", e)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
self.global_toml_config = Some(config);
let Some(ref global_config) = self.global_toml_config else {
return;
};
let ai_config = build_provider_config(global_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config);
if let Some(agent_manager) = &self.main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetProvider(Box::new(provider)))
.await;
}
self.insert_before_queued(UiMessages(format!(
"Switched to model: {} , context: {} , please start the conversation again",
model,
ctx_val.map_or("200k".to_string(), format_token_count),
)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
async fn switch_claude_skills(&mut self) {
let current = self
.global_toml_config
.as_ref()
.and_then(|c| c.read_claude_skills)
.unwrap_or(true);
let new_val = !current;
let config = GlobalTomlConfig {
base_url: None,
api_key: None,
model: None,
theme: None,
reasoning_effort: None,
context_capacity: None,
read_claude_skills: Some(new_val),
};
if let Err(e) = config.save() {
self.insert_before_queued(UiMessages(format!("Failed to save config: {}", e)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
return;
}
if let Some(ref mut global_config) = self.global_toml_config {
global_config.read_claude_skills = Some(new_val);
}
let new_skills = oy_agent::domain::skill::discover_skills(new_val);
self.skills = new_skills.clone();
if let Some(ref agent_manager) = self.main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetSkills(new_skills))
.await;
}
let status = if new_val { "on" } else { "off" };
self.insert_before_queued(UiMessages(format!(
"Reading ~/.claude/skills/ is now {}",
status
)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
self.app_mode = AppMode::Normal;
self.input.clear();
self.cursor_pos = 0;
}
fn expand_paste_snippets(&mut self) {
let snippets = std::mem::take(&mut self.paste_snippets);
for (id, content) in snippets {
let placeholder = format!("[{} +{} lines]", id, content.lines().count());
while let Some(pos) = self.input.find(&placeholder) {
self.input
.replace_range(pos..pos + placeholder.len(), &content);
if pos + placeholder.len() <= self.cursor_pos {
self.cursor_pos = self.cursor_pos - placeholder.len() + content.len();
} else if pos < self.cursor_pos {
self.cursor_pos = pos + content.len();
}
}
}
}
fn adjust_scroll(selected: usize, total: usize, max_visible: usize) -> usize {
if total <= max_visible {
return 0;
}
if selected < max_visible {
0
} else if selected + max_visible >= total {
total - max_visible
} else {
selected - max_visible / 2
}
}
async fn handle_key_revoke_select(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
match key_event.code {
KeyCode::Char(c @ '1'..='9') => {
let target_number = (c as u8) - b'1' + 1;
self.revoke_prompt_by_number(target_number).await;
self.app_mode = AppMode::Normal;
},
KeyCode::Esc | KeyCode::Enter => {
self.app_mode = AppMode::Normal;
},
_ => {},
}
Ok(())
}
fn insert_before_queued(&mut self, msg: Message) {
let pos = self
.messages
.iter()
.position(|m| matches!(m, Message::PromptQueued { .. }));
match pos {
Some(idx) => self.messages.insert(idx, msg),
None => self.messages.push_back(msg),
}
}
async fn revoke_prompt_by_number(&mut self, number: u8) {
let mut count = 0u8;
let idx = self.messages.iter().position(|m| {
if matches!(m, Message::PromptQueued { .. }) {
count += 1;
count == number
} else {
false
}
});
let Some(idx) = idx else {
return;
};
let (id, text) = match &self.messages[idx] {
Message::PromptQueued { id, text } => (*id, text.clone()),
_ => return,
};
self.messages.remove(idx);
self.pending_prompts.retain(|x| *x != id);
let active_sender = match self.active_agent {
AgentType::MainAgent => self.main_agent.as_ref(),
AgentType::CommanderAgent => self.commander_agent.as_ref(),
};
if let Some(agent) = active_sender {
let _ = agent
.request_sender
.send(RequestAgent::CancelPrompt { id })
.await;
}
if !self.input.is_empty() {
self.input.push('\n');
self.cursor_pos = self.input.len();
}
self.input.push_str(&text);
self.cursor_pos = self.input.len();
}
pub async fn switch_agent(&mut self) {
let history = match self.active_agent {
AgentType::MainAgent => self.get_agent_messages(&self.main_agent).await,
AgentType::CommanderAgent => self.get_agent_messages(&self.commander_agent).await,
};
self.active_agent = match self.active_agent {
AgentType::MainAgent => AgentType::CommanderAgent,
AgentType::CommanderAgent => AgentType::MainAgent,
};
let to_name = match self.active_agent {
AgentType::MainAgent => "MainAgent",
AgentType::CommanderAgent => "CommanderAgent",
};
if !history.is_empty() {
match self.active_agent {
AgentType::MainAgent => self.set_agent_messages(&self.main_agent, &history).await,
AgentType::CommanderAgent => {
self.set_agent_messages(&self.commander_agent, &history)
.await
},
}
}
self.insert_before_queued(UiMessages(format!("Switched to {}.", to_name)));
self.scroll_offset.set(u16::MAX);
self.auto_scroll.set(true);
}
async fn get_agent_messages(&self, agent: &Option<AgentManager>) -> Vec<ChatMessage> {
let agent = match agent {
Some(a) => a,
None => return vec![],
};
let (tx, rx) = tokio::sync::oneshot::channel();
if agent
.request_sender
.send(RequestAgent::GetMessages { tx })
.await
.is_err()
{
return vec![];
}
rx.await.unwrap_or_default()
}
async fn set_agent_messages(&self, agent: &Option<AgentManager>, msgs: &[ChatMessage]) {
let agent = match agent {
Some(a) => a,
None => return,
};
let _ = agent
.request_sender
.send(RequestAgent::SetMessages(msgs.to_vec()))
.await;
}
pub fn tick(&self) {
self.tick_counter.set(self.tick_counter.get() + 1);
}
pub fn quit(&mut self) {
self.running = false;
}
}
pub fn config_is_complete(config: &GlobalTomlConfig) -> bool {
config.api_key.as_ref().is_some_and(|s| !s.is_empty())
&& config.base_url.as_ref().is_some_and(|s| !s.is_empty())
&& config.model.as_ref().is_some_and(|s| !s.is_empty())
}
pub async fn start_agent_with_session(
global_toml_config: &GlobalTomlConfig,
session_uuid: Uuid,
session_messages: Vec<ChatMessage>,
) -> AgentManager {
let ai_config = build_provider_config(global_toml_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config);
let mut tool_registry = ToolRegistry::new();
register_default_tools(&mut tool_registry);
let main_agent = MainAgent::new(None);
let (request_sender, response_receiver, join_handle) = Orchestrator::start_with_session(
main_agent,
provider,
tool_registry,
session_uuid,
session_messages,
);
AgentManager::new(
"MainAgent".to_owned(),
join_handle,
request_sender,
response_receiver,
)
}
pub async fn start_main_agent_background(
global_toml_config: &GlobalTomlConfig,
session_uuid: Uuid,
) -> AgentManager {
let ai_config = build_provider_config(global_toml_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config);
let mut tool_registry = ToolRegistry::new();
register_default_tools(&mut tool_registry);
let main_agent = MainAgent::new(None);
let (request_sender, response_receiver, join_handle) =
Orchestrator::start_with_session(main_agent, provider, tool_registry, session_uuid, vec![]);
AgentManager::new(
"MainAgent".to_owned(),
join_handle,
request_sender,
response_receiver,
)
}
pub async fn start_commander_agent_background(
global_toml_config: &GlobalTomlConfig,
session_uuid: Uuid,
) -> AgentManager {
let ai_config = build_provider_config(global_toml_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config.clone());
let provider_for_sub_agents = Arc::new(OpenCodeGoProvider::new(ai_config));
let file_tools = Arc::new({
let mut r = ToolRegistry::new();
register_default_tools(&mut r);
r
});
let mut commander_registry = ToolRegistry::new();
register_default_tools(&mut commander_registry);
commander_registry.register(CreateSubAgentTool::new(
provider_for_sub_agents.clone(),
file_tools.clone(),
));
let commander_agent = CommanderAgent::new(None);
let (request_sender, response_receiver, join_handle) =
Orchestrator::start_commander_with_session(
commander_agent,
provider,
commander_registry,
session_uuid,
vec![],
provider_for_sub_agents,
file_tools,
);
AgentManager::new(
"CommanderAgent".to_owned(),
join_handle,
request_sender,
response_receiver,
)
}
pub async fn start_commander_agent_with_session(
global_toml_config: &GlobalTomlConfig,
session_uuid: Uuid,
session_messages: Vec<ChatMessage>,
) -> AgentManager {
let ai_config = build_provider_config(global_toml_config)
.expect("config_is_complete guarantees api_key is set");
let provider = OpenCodeGoProvider::new(ai_config.clone());
let provider_for_sub_agents = Arc::new(OpenCodeGoProvider::new(ai_config));
let file_tools = Arc::new({
let mut r = ToolRegistry::new();
register_default_tools(&mut r);
r
});
let mut commander_registry = ToolRegistry::new();
register_default_tools(&mut commander_registry);
commander_registry.register(CreateSubAgentTool::new(
provider_for_sub_agents.clone(),
file_tools.clone(),
));
let commander_agent = CommanderAgent::new(None);
let (request_sender, response_receiver, join_handle) =
Orchestrator::start_commander_with_session(
commander_agent,
provider,
commander_registry,
session_uuid,
session_messages,
provider_for_sub_agents,
file_tools,
);
AgentManager::new(
"CommanderAgent".to_owned(),
join_handle,
request_sender,
response_receiver,
)
}
pub(crate) fn visual_cursor_pos(input: &str, cursor_pos: usize, width: usize) -> (u16, u16) {
if input.is_empty() || width == 0 {
return (0, 0);
}
let mut row = 0u16;
let mut col = 0u16;
let mut pending_ws = 0u16;
for (i, ch) in input.char_indices() {
if i >= cursor_pos {
col += pending_ws;
break;
}
if ch == '\n' {
pending_ws = 0;
row += 1;
col = 0;
} else {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
if ch.is_ascii_whitespace() && ch != '\n' {
if col + pending_ws + w > width as u16 {
row += 1;
pending_ws = 0;
col = 0;
} else {
pending_ws += w;
}
} else {
if col + pending_ws + w > width as u16 {
row += 1;
col = w;
} else {
col += pending_ws + w;
}
pending_ws = 0;
}
}
}
col += pending_ws;
if col >= width as u16 {
row += 1;
col = 0;
}
(row, col)
}
#[cfg(test)]
mod app_tests {
use super::*;
use oy_agent::oy_ai::ChatMessage;
use std::cell::Cell;
use std::collections::HashMap;
fn make_app_with_messages(msgs: Vec<Message>) -> App {
let (_, rx) = tokio::sync::mpsc::channel::<oy_agent::agent::ResponseAgent>(1);
App {
running: true,
messages: VecDeque::from(msgs),
input: String::new(),
cursor_pos: 0,
cursor_x: Cell::new(0),
cursor_y: Cell::new(0),
input_width: Cell::new(0),
scroll_offset: Cell::new(0),
auto_scroll: Cell::new(true),
last_chat_width: Cell::new(0),
paste_snippets: HashMap::new(),
paste_counter: 0,
events: EventHandler::new_with_receiver(rx),
global_toml_config: None,
main_agent: None,
commander_agent: None,
active_agent: AgentType::MainAgent,
command_registry: CommandRegistry::new(),
app_mode: AppMode::Normal,
input_title: String::new(),
theme: &LIGHT_THEME,
agent_status: Cell::new(Status::Pause),
tick_counter: Cell::new(0),
token_usage: TokenUsage::new(),
skills: Vec::new(),
pending_prompts: Vec::new(),
sub_agent_states: Vec::new(),
sub_agent_scroll: Cell::new(0),
sub_agent_panel_y: Cell::new(u16::MAX),
session_uuid: None,
user_history: Vec::new(),
history_index: None,
}
}
#[tokio::test]
async fn test_extract_user_history_dedup_consecutive() {
let msgs = vec![
Message::AgentMessages(ChatMessage::user("hello"), false),
Message::AgentMessages(ChatMessage::user("fix bug"), false),
Message::AgentMessages(ChatMessage::user("hello"), false),
Message::AgentMessages(ChatMessage::user("fix bug"), false),
Message::AgentMessages(ChatMessage::user("test"), false),
];
let app = make_app_with_messages(msgs);
let history = app.extract_user_history();
assert_eq!(
history,
vec!["test", "fix bug", "hello", "fix bug", "hello"]
);
}
#[tokio::test]
async fn test_extract_user_history_no_duplicates() {
let msgs = vec![
Message::AgentMessages(ChatMessage::user("a"), false),
Message::AgentMessages(ChatMessage::user("b"), false),
Message::AgentMessages(ChatMessage::user("c"), false),
];
let app = make_app_with_messages(msgs);
let history = app.extract_user_history();
assert_eq!(history, vec!["c", "b", "a"]);
}
#[tokio::test]
async fn test_extract_user_history_empty() {
let app = make_app_with_messages(vec![]);
assert!(app.extract_user_history().is_empty());
}
#[tokio::test]
async fn test_extract_user_history_no_user_messages() {
let msgs = vec![
Message::AgentMessages(ChatMessage::assistant(Some("hi".into()), None, None), false),
Message::AgentMessages(ChatMessage::tool("result", "id".into(), None, None), false),
];
let app = make_app_with_messages(msgs);
assert!(app.extract_user_history().is_empty());
}
#[tokio::test]
async fn test_extract_user_history_all_same() {
let msgs = vec![
Message::AgentMessages(ChatMessage::user("same"), false),
Message::AgentMessages(ChatMessage::user("same"), false),
Message::AgentMessages(ChatMessage::user("same"), false),
];
let app = make_app_with_messages(msgs);
let history = app.extract_user_history();
assert_eq!(history, vec!["same"]);
}
#[tokio::test]
async fn test_extract_user_history_mixed_roles() {
let msgs = vec![
Message::AgentMessages(ChatMessage::user("user1"), false),
Message::AgentMessages(
ChatMessage::assistant(Some("resp".into()), None, None),
false,
),
Message::AgentMessages(ChatMessage::user("user2"), false),
Message::AgentMessages(ChatMessage::tool("result", "id".into(), None, None), false),
Message::AgentMessages(ChatMessage::user("user2"), false),
];
let app = make_app_with_messages(msgs);
let history = app.extract_user_history();
assert_eq!(history, vec!["user2", "user1"]);
}
}