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::{
Orchestrator, SkillSummary, TokenUsage,
agent::{PromptKind, RequestAgent},
format_token_count,
infrastructure::{agents::main_agent::MainAgent, tools::ToolRegistry},
oy_ai::OpenCodeGoProvider,
};
use ratatui::DefaultTerminal;
use std::{
cell::Cell,
collections::{HashMap, VecDeque},
time::Instant,
};
use uuid::Uuid;
const MAX_POPUP_ROWS: usize = 4;
#[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)]
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 paste_snippets: HashMap<String, String>,
pub paste_counter: usize,
pub events: EventHandler,
pub global_toml_config: Option<GlobalTomlConfig>,
pub main_agent: Option<AgentManager>,
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>,
}
impl App {
pub async fn new() -> Self {
let mut messages = VecDeque::new();
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()));
}
});
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);
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(", ")
)));
}
let mut main_agent: Option<AgentManager> = None;
if let Some(global_toml_config) = &global_toml_config {
main_agent = Some(start_main_agent_background(global_toml_config).await);
}
if let Some(ref agent_manager) = main_agent {
let _ = agent_manager
.request_sender
.send(RequestAgent::SetSkills(skills.clone()))
.await;
}
let events = if let Some(agent_manager) = &mut main_agent {
if let Some(response_receiver) = agent_manager.response_receiver.take() {
EventHandler::new_with_receiver(response_receiver)
} else {
EventHandler::new()
}
} else {
EventHandler::new()
};
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);
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(0),
auto_scroll: Cell::new(true),
paste_snippets: HashMap::new(),
paste_counter: 0,
events,
global_toml_config,
main_agent,
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(),
}
}
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 => {
self.scroll_offset
.set(self.scroll_offset.get().saturating_add(3));
}
MouseEventKind::ScrollUp => {
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(())
}
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 {
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 {
None
},
tool_call_id: tc.id.clone(),
result: None,
start_time: Instant::now(),
end_time: None,
expanded: false,
}));
}
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
{
for msg in self.messages.iter_mut().rev() {
if let ToolCallMsg(state) = msg
&& state.result.is_none()
&& state.tool_call_id == *call_id
{
state.result = Some(chat_message);
state.end_time = Some(Instant::now());
break;
}
}
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,
}
}
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.app_mode = AppMode::Normal;
}
}
KeyCode::Enter if !self.input.is_empty() => {
self.expand_paste_snippets();
let input = std::mem::take(&mut self.input);
self.cursor_pos = 0;
self.paste_counter = 0;
self.scroll_offset.set(u16::MAX);
let kind = if key_event.modifiers == KeyModifiers::ALT {
PromptKind::AltEnter
} else {
PromptKind::Enter
};
if input.starts_with('/') {
self.execute_command(&input).await;
} else if let Some(main_agent) = &self.main_agent {
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 Ok(());
}
let id = Uuid::now_v7();
let _ = main_agent
.request_sender
.send(RequestAgent::Prompt {
text: input.clone(),
id,
kind,
})
.await;
self.pending_prompts.push(id);
self.insert_before_queued(Message::PromptQueued { id, text: input });
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
} else {
self.insert_before_queued(UiMessages(
"Agent not initialized. Please use /model to configure your API key and model first.".to_string()
));
}
}
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;
}
}
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 width > 0 {
self.move_cursor_up(width);
}
}
KeyCode::Down => {
let width = self.input_width.get() as usize;
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::Char(c) => {
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.app_mode = AppMode::CommandSelector {
selected: 0,
scroll_offset: 0,
};
}
}
_ => {}
}
Ok(())
}
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(())
}
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 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()],
};
}
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()],
};
}
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()],
};
}
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(),
],
};
} 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) {
let trimmed = input.trim();
if let Some(cmd) = self.command_registry.search(trimmed).first()
&& !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,
};
return;
}
if trimmed == "/model" || trimmed.starts_with("/model ") {
self.input_title = "API Base URL:".to_string();
self.app_mode = AppMode::ModelForm {
step: 0,
values: [String::new(), String::new(), String::new(), String::new()],
};
} else {
self.insert_before_queued(UiMessages(format!("Unknown command: {}", trimmed)));
if self.auto_scroll.get() {
self.scroll_offset.set(u16::MAX);
}
}
}
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 {
let ai_config = build_provider_config(global_config);
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 {
let ai_config = build_provider_config(global_config);
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);
if let Some(ref global_config) = self.global_toml_config {
let ai_config = build_provider_config(global_config);
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)));
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);
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);
if let Some(main_agent) = &self.main_agent {
let _ = main_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 fn tick(&self) {
self.tick_counter.set(self.tick_counter.get() + 1);
}
pub fn quit(&mut self) {
self.running = false;
}
}
pub async fn start_main_agent_background(global_toml_config: &GlobalTomlConfig) -> AgentManager {
let ai_config = build_provider_config(global_toml_config);
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(main_agent, provider, tool_registry);
AgentManager::new(
"MainAgent".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)
}