use super::app::{AppOverlay, AppState, ProviderInfo, SetupStep, UiEvent};
use super::overlay::router_integration;
use super::slash;
use crate::app::agent_session::{AgentSession, SessionEvent};
use crate::context::auto_compaction::CompactionReason;
use crate::media::clipboard_write;
use base64::Engine;
use oxi_agent::AgentEvent;
use oxi_tui::widgets::chat::ToolCallStatus;
use tokio::sync::mpsc;
use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
pub(crate) enum Action {
SendPrompt(String),
ExecuteSlashCommand(String),
}
fn remove_from_steering_queue(session: &AgentSession, index: usize) -> Option<String> {
let queue = session.steering_queue();
let mut guard = queue.write();
if index < guard.len() {
guard.remove(index)
} else {
None
}
}
pub async fn handle_input(
event: CEvent,
state: &mut AppState,
session: &AgentSession,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
_prompt_tx: &mpsc::Sender<String>,
running: &mut bool,
) -> Option<Action> {
match event {
CEvent::Key(key) => {
if state.overlay.is_some() || state.overlay_state.is_some() {
handle_overlay_key(key, state, session).await
} else {
handle_key(key, state, session, ui_tx, running).await
}
}
CEvent::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollUp => state.scroll_up(3),
MouseEventKind::ScrollDown => state.scroll_down(3),
MouseEventKind::Up(button) => {
use crossterm::event::MouseButton;
if button == MouseButton::Left {
handle_click(mouse.column, mouse.row, state);
}
}
_ => {}
}
None
}
CEvent::Paste(text) => {
if state.overlay.is_some() || state.overlay_state.is_some() {
handle_overlay_paste(&text, state)
} else {
state.input.insert_str(&text);
state.update_slash_completions();
update_file_completions(state);
None
}
}
_ => None,
}
}
async fn handle_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
session: &AgentSession,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
running: &mut bool,
) -> Option<Action> {
if key.kind != KeyEventKind::Press {
return None;
}
if state.queue_panel_visible && !state.steering_messages_snapshot.is_empty() {
if let Some(action) = handle_queue_panel_key(key, state, session) {
return action;
}
}
use oxi_tui::keybindings::keys::KeyId;
let key_id = KeyId::from(key);
if let Some(action) = state.keybindings.match_action(&key_id) {
return dispatch_action(action, key, state, session, ui_tx, running).await;
}
if !key_id.ctrl && !key_id.alt && !key_id.super_ {
if let oxi_tui::keybindings::keys::BaseKey::Char(c) = key_id.base {
state.input.insert_char(c);
state.update_slash_completions();
update_file_completions(state);
}
}
None
}
fn handle_queue_panel_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
session: &AgentSession,
) -> Option<Option<Action>> {
match key.code {
KeyCode::Up if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.queue_panel_selected > 0 {
state.queue_panel_selected -= 1;
}
Some(None)
}
KeyCode::Down if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.queue_panel_selected < state.steering_messages_snapshot.len() - 1 {
state.queue_panel_selected += 1;
}
Some(None)
}
KeyCode::Delete | KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let idx = state.queue_panel_selected;
let removed = remove_from_steering_queue(session, idx);
if let Some(msg) = removed {
let preview: String = msg.chars().take(40).collect();
state.add_system_message(format!("Removed: {}", preview));
}
refresh_queue_snapshot(state, session);
Some(None)
}
KeyCode::Char('e') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let idx = state.queue_panel_selected;
let removed = remove_from_steering_queue(session, idx);
if let Some(msg) = removed {
state.input_set_text(msg);
}
refresh_queue_snapshot(state, session);
state.queue_panel_selected = state.steering_messages_snapshot.len().saturating_sub(1);
state.queue_panel_visible = false;
Some(None)
}
KeyCode::Esc => {
state.queue_panel_visible = false;
Some(None)
}
_ => None, }
}
fn refresh_queue_snapshot(state: &mut AppState, session: &AgentSession) {
let msgs = session.steering_messages();
let fq = session.follow_up_messages();
let pending = msgs.len() + fq.len();
let mut all = msgs;
all.extend(fq);
state.pending_steering = pending;
state.steering_messages_snapshot = all;
if state.queue_panel_selected >= state.steering_messages_snapshot.len() {
state.queue_panel_selected = state.steering_messages_snapshot.len().saturating_sub(1);
}
}
async fn dispatch_action(
action: oxi_tui::keybindings::registry::Action,
key: crossterm::event::KeyEvent,
state: &mut AppState,
session: &AgentSession,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
running: &mut bool,
) -> Option<Action> {
use oxi_tui::keybindings::registry::Action as KAction;
match action {
KAction::Submit => handle_submit(state, session, running, ui_tx).await,
KAction::Quit => {
tracing::debug!("[TUI-Handler] Ctrl+C setting running = false");
*running = false;
session.agent_ref().cancel();
session.abort_compaction_sync();
tracing::debug!("[TUI-Handler] Ctrl+C done, running = {}", *running);
None
}
KAction::Cancel => {
if state.footer_state.data.is_compacting {
session.abort_compaction_sync();
state.footer_state.data.is_compacting = false;
state.add_system_message("Compaction cancelled".to_string());
} else if state.slash_completion_active {
state.clear_slash_completions();
}
None
}
KAction::CursorLeft => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
state.input.move_word_left();
} else {
state.input.move_left();
}
None
}
KAction::CursorRight => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
state.input.move_word_right();
} else {
state.input.move_right();
}
None
}
KAction::CursorWordLeft => {
state.input.move_word_left();
None
}
KAction::CursorWordRight => {
state.input.move_word_right();
None
}
KAction::CursorLineStart => {
state.input.move_home();
None
}
KAction::CursorLineEnd => {
state.input.move_end();
None
}
KAction::DeleteCharBackward => {
state.input.backspace();
state.update_slash_completions();
update_file_completions(state);
None
}
KAction::DeleteCharForward => {
state.input.delete();
state.update_slash_completions();
update_file_completions(state);
None
}
KAction::DeleteToLineStart => {
state.input.delete_to_line_start();
state.update_slash_completions();
update_file_completions(state);
None
}
KAction::DeleteToLineEnd => {
state.input.delete_to_line_end();
state.update_slash_completions();
update_file_completions(state);
None
}
KAction::Undo => {
state.input.undo();
None
}
KAction::Tab => {
if state.slash_completion_active {
let cmd = state.selected_slash_command().map(|c| c.name.clone());
state.clear_slash_completions();
state.input_clear();
if let Some(cmd) = cmd {
return Some(Action::ExecuteSlashCommand(cmd));
}
}
None
}
KAction::CycleThinking => {
if !state.slash_completion_active {
if let Some(next_level) = session.cycle_thinking_level() {
state.footer_state.data.thinking_level =
Some(format!("{:?}", next_level).to_lowercase());
state.add_system_message(format!("Thinking: {:?}", next_level));
}
}
None
}
KAction::ScrollUp => {
if state.slash_completion_active {
state.prev_slash_completion();
} else if state.input.text().is_empty() && !state.input_history.is_empty() {
navigate_history_up(state);
} else {
state.scroll_up(3);
}
None
}
KAction::ScrollDown => {
if state.slash_completion_active {
state.next_slash_completion();
} else if state.history_index > 0 {
navigate_history_down(state);
} else {
state.scroll_down(3);
}
None
}
KAction::ScrollPageUp => {
state.scroll_up(10);
None
}
KAction::ScrollPageDown => {
state.scroll_down(10);
None
}
KAction::OpenImage => {
open_last_image(state);
None
}
KAction::ToggleRouting => {
state.overlay_state = None;
let snap = oxi_ai::router::RouterProvider::get_snapshot();
let data = if let Some(ref s) = snap {
use oxi_tui::widgets::routing::{ProviderHealth, ProviderInfo, RoutingStatusData};
let chain = vec![ProviderInfo {
name: s.last_provider.clone().unwrap_or_default(),
health: ProviderHealth::Healthy,
failures: 0,
is_active: true,
}];
RoutingStatusData {
auto_routing_enabled: true,
fallback_enabled: true,
fallback_chain: chain,
active_index: 0,
}
} else {
oxi_tui::widgets::routing::RoutingStatusData::default()
};
state.overlay_state = Some(super::overlay::factories::routing_status(data));
None
}
KAction::ToggleQueue => {
state.queue_panel_visible = !state.queue_panel_visible;
if state.queue_panel_visible {
state.queue_panel_selected =
state.steering_messages_snapshot.len().saturating_sub(1);
}
None
}
KAction::CopyCodeBlock => {
if let Some(ref code) = state.chat.last_code_block {
match clipboard_write::copy_to_clipboard(code) {
Ok(()) => state.add_system_message("\u{2713} Code block copied".to_string()),
Err(e) => state.add_system_message(format!("\u{2717} Copy failed: {}", e)),
}
} else {
state.add_system_message("No code block to copy".to_string());
}
None
}
KAction::DeleteWordBackward => {
state.input.delete_word_backward();
state.update_slash_completions();
update_file_completions(state);
None
}
KAction::DeleteWordForward => {
state.input.delete_word_forward();
state.update_slash_completions();
update_file_completions(state);
None
}
KAction::OpenModelSelect
| KAction::OpenProviderSetup
| KAction::NewLine
| KAction::HistoryUp
| KAction::HistoryDown
| KAction::CompletionNext
| KAction::CompletionPrev
| KAction::CompletionDismiss
| KAction::CompletionAccept => None,
}
}
async fn handle_submit(
state: &mut AppState,
session: &AgentSession,
running: &mut bool,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
) -> Option<Action> {
let value = state.input_value().to_string();
if value.is_empty() {
return None;
}
if state.slash_completion_active {
let cmd = state.selected_slash_command().map(|c| c.name.clone());
state.clear_slash_completions();
state.input_clear();
if let Some(cmd) = cmd {
return Some(Action::ExecuteSlashCommand(cmd));
}
return None;
}
if value.starts_with('/') {
let handled = slash::handle_slash_command(&value, session, state, running, ui_tx);
state.input_clear();
if handled {
return None;
}
}
if state.is_agent_busy {
state.add_system_message(format!(
"Queued: {}",
value.chars().take(50).collect::<String>()
));
state.input_history.insert(0, value.clone());
if state.input_history.len() > 100 {
state.input_history.remove(0);
}
state.history_index = 0;
session.steer_sync(value.clone());
state.steering_messages_snapshot.push(value);
state.pending_steering = state.steering_messages_snapshot.len();
state.input_clear();
return None;
}
Some(Action::SendPrompt(value))
}
fn navigate_history_up(state: &mut AppState) {
if state.history_index == 0 {
state.saved_input = state.input.text();
}
if state.history_index < state.input_history.len() {
state.history_index += 1;
state.input_set_text(state.input_history[state.history_index - 1].clone());
state.clear_slash_completions();
}
}
fn navigate_history_down(state: &mut AppState) {
state.history_index -= 1;
if state.history_index == 0 {
state.input_set_text(state.saved_input.clone());
} else {
state.input_set_text(state.input_history[state.history_index - 1].clone());
}
state.clear_slash_completions();
}
pub fn handle_ui_event(event: UiEvent, state: &mut AppState) {
match event {
UiEvent::AgentStart => {
}
UiEvent::AgentEnd => {
state.is_agent_busy = false;
state.needs_persist = true;
}
UiEvent::TurnStart { .. } => {
}
UiEvent::TurnEnd { .. } => {
}
UiEvent::MessageStart { message } => {
let auto_committed = state.chat.start_streaming();
if auto_committed {
state.message_count += 1;
state.chat.refresh_last_code_block();
}
state.is_agent_busy = true;
state.auto_scroll = true;
state.reset_snapshot_tracking();
state.update_streaming_message(&message, None);
}
UiEvent::MessageUpdate { message, delta } => {
state.update_streaming_message(&message, delta.as_deref());
}
UiEvent::MessageEnd { message } => {
state.finalize_streaming_message(&message);
state.needs_persist = true;
let was_streaming = state.chat.is_streaming();
state.chat.finish_streaming();
if was_streaming {
state.message_count += 1;
state.chat.refresh_last_code_block();
}
}
UiEvent::ToolExecutionStart {
tool_call_id,
tool_name,
args,
} => {
tracing::debug!(
"[HANDLER] ToolExecutionStart: id={:?}, name={:?}",
tool_call_id,
tool_name
);
state
.tool_start_times
.insert(tool_call_id.clone(), std::time::Instant::now());
let args_str = serde_json::to_string(&args).unwrap_or_else(|_| args.to_string());
state.chat.stream_tool_call(
tool_call_id,
tool_name,
args_str,
ToolCallStatus::Executing,
);
}
UiEvent::ToolExecutionEnd {
tool_call_id,
tool_name,
result,
is_error,
} => {
tracing::debug!(
"[HANDLER] ToolExecutionEnd: id={:?}, name={:?}",
tool_call_id,
tool_name
);
let duration = state.tool_start_times.remove(&tool_call_id).map(|t| {
let elapsed = t.elapsed();
if elapsed.as_secs() >= 60 {
format!("{}m{}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
} else if elapsed.as_millis() >= 1000 {
format!("{:.1}s", elapsed.as_secs_f64())
} else {
format!("{}ms", elapsed.as_millis())
}
});
let content = result.content.clone();
state.chat.stream_tool_result(
Some(tool_call_id.clone()),
tool_name,
content,
is_error || result.status == "error",
);
if let Some(dur) = duration {
state.chat.set_tool_duration(&tool_call_id, dur);
}
}
UiEvent::Thinking => {
}
UiEvent::ThinkingDelta(text) => {
state.chat.stream_thinking(text, true);
}
UiEvent::Error(msg) => {
state.cancel_streaming();
state.add_system_message(format!("Error: {}", msg));
}
UiEvent::CompactionStart { reason: _reason } => {
state.footer_state.data.is_compacting = true;
let label = match _reason {
CompactionReason::Manual => "Compacting context...",
CompactionReason::Threshold | CompactionReason::Automatic => "Auto-compacting...",
CompactionReason::Overflow => "Context overflow, compacting...",
CompactionReason::Iteration { .. } => "Auto-compacting (iteration)...",
};
state.add_system_message(format!("{} (Esc to cancel)", label));
}
UiEvent::CompactionEnd {
_reason,
error_message,
} => {
state.footer_state.data.is_compacting = false;
if let Some(err) = error_message {
state.add_system_message(format!("Compaction failed: {}", err));
} else {
state.needs_chat_rebuild = true;
}
}
UiEvent::RetryStart {
attempt,
max_attempts,
error_message,
} => {
state.add_system_message(format!(
"Retry ({}/{}): {}",
attempt, max_attempts, error_message
));
}
UiEvent::ModelChanged { model_id } => {
state.add_system_message(format!("Model: {}", model_id));
state.footer_state.data.model_name = model_id;
}
UiEvent::ThinkingLevelChanged { level } => {
state.add_system_message(format!("Thinking: {}", level));
state.footer_state.data.thinking_level = Some(level.to_lowercase());
}
UiEvent::QueueUpdate { pending, messages } => {
state.pending_steering = pending;
if messages.len() < state.steering_messages_snapshot.len() {
let consumed = state.steering_messages_snapshot.len() - messages.len();
state.steering_messages_snapshot =
state.steering_messages_snapshot.drain(consumed..).collect();
} else {
state.steering_messages_snapshot = messages;
}
if state.queue_panel_selected >= state.steering_messages_snapshot.len() {
state.queue_panel_selected =
state.steering_messages_snapshot.len().saturating_sub(1);
}
}
UiEvent::AutoProcessStart { prompt } => {
state.add_user_message(prompt.clone());
state.input_history.insert(0, prompt.clone());
if state.input_history.len() > 100 {
state.input_history.remove(0);
}
state.history_index = 0;
state.start_streaming();
if let Some(pos) = state
.steering_messages_snapshot
.iter()
.position(|m| m == &prompt)
{
state.steering_messages_snapshot.remove(pos);
} else if !state.steering_messages_snapshot.is_empty() {
state.steering_messages_snapshot.remove(0);
}
state.pending_steering = state.steering_messages_snapshot.len();
if state.queue_panel_selected >= state.steering_messages_snapshot.len() {
state.queue_panel_selected =
state.steering_messages_snapshot.len().saturating_sub(1);
}
}
UiEvent::SystemMessage(msg) => {
state.add_system_message(msg);
}
UiEvent::TokenUsage {
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
context_window_pct,
total_cost,
} => {
state.footer_state.data.input_tokens = input_tokens;
state.footer_state.data.output_tokens = output_tokens;
state.footer_state.data.cache_read_tokens = cache_read_tokens;
state.footer_state.data.cache_write_tokens = cache_write_tokens;
state.footer_state.data.context_window_pct = context_window_pct;
state.footer_state.data.total_cost = total_cost;
state.footer_state.data.context_tokens =
input_tokens + output_tokens + cache_read_tokens + cache_write_tokens;
}
}
}
pub async fn handle_session_event(event: SessionEvent, ui_tx: &mpsc::UnboundedSender<UiEvent>) {
match event {
SessionEvent::CompactionStart { reason } => {
let _ = ui_tx.send(UiEvent::CompactionStart { reason });
}
SessionEvent::CompactionEnd {
reason,
error_message,
..
} => {
let _ = ui_tx.send(UiEvent::CompactionEnd {
_reason: reason,
error_message,
});
}
SessionEvent::ThinkingLevelChanged { level } => {
let _ = ui_tx.send(UiEvent::ThinkingLevelChanged {
level: format!("{:?}", level),
});
}
SessionEvent::QueueUpdate {
steering,
follow_up,
} => {
let pending = steering.len() + follow_up.len();
let mut all_messages = steering;
all_messages.extend(follow_up);
let _ = ui_tx.send(UiEvent::QueueUpdate {
pending,
messages: all_messages,
});
}
SessionEvent::SessionInfoChanged => {}
SessionEvent::Agent(agent_event) => match &agent_event {
AgentEvent::Fallback { to_model, .. } => {
let _ = ui_tx.send(UiEvent::ModelChanged {
model_id: to_model.clone(),
});
}
AgentEvent::Retry {
attempt,
max_retries,
reason,
..
} => {
let _ = ui_tx.send(UiEvent::RetryStart {
attempt: *attempt as u32,
max_attempts: *max_retries as u32,
error_message: reason.clone(),
});
}
AgentEvent::Compaction { .. } => {}
_ => {}
},
}
}
fn open_last_image(state: &mut AppState) {
if let Some((base64_data, mime_type)) = state.chat.pending_images.last().cloned() {
match base64::engine::general_purpose::STANDARD.decode(&base64_data) {
Ok(bytes) => {
let ext = match mime_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/bmp" => "bmp",
_ => "bin",
};
let path = std::env::temp_dir().join(format!("oxi_image.{}", ext));
match std::fs::write(&path, &bytes) {
Ok(()) => {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(&path).spawn().ok();
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.ok();
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/c", "start"])
.arg(&path)
.spawn()
.ok();
}
state.add_system_message("Opened image in viewer".to_string());
}
Err(e) => {
state.add_system_message(format!("Failed to write image: {}", e));
}
}
}
Err(e) => {
state.add_system_message(format!("Failed to decode image: {}", e));
}
}
} else {
state.add_system_message("No images to display".to_string());
}
}
async fn handle_overlay_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
session: &AgentSession,
) -> Option<Action> {
if key.kind != KeyEventKind::Press {
return None;
}
if let Some(ref mut overlay) = state.overlay_state {
use super::overlay::OverlayAction;
let action = overlay.handle_key(key);
match action {
OverlayAction::Close => {
state.overlay_state = None;
state.overlay = None;
}
OverlayAction::SwitchSession(path) => {
state.next_action = Some(super::app::TuiNextAction::SwitchSession(path));
state.overlay_state = None;
}
OverlayAction::NewSession => {
state.next_action = Some(super::app::TuiNextAction::NewSession);
state.overlay_state = None;
}
OverlayAction::OpenRouterSetup { initial, models } => {
state.overlay_state = None;
state.overlay_state = Some(super::overlay::router_setup(
initial,
models,
move |data: &super::overlay::RouterSetupData| {
let store_cfg = router_integration::save_router_config(data)?;
let ai_cfg = router_integration::store_config_to_ai_config(&store_cfg);
oxi_ai::router::register_router(&ai_cfg);
Ok(())
},
|| {},
));
return None;
}
OverlayAction::ForkFromEntry { entry_id } => {
state.overlay_state = None;
if let Some(ref path) = state.session_file_path {
let sm = oxi_store::session::SessionManager::open(path, None, None);
match sm.branch_from_entry(&entry_id) {
Ok(new_path) => {
state.next_action =
Some(super::app::TuiNextAction::SwitchSession(new_path));
state.add_system_message(format!(
"Forked from [{}]\nStarting new session...",
&entry_id[..8.min(entry_id.len())]
));
}
Err(e) => {
state.add_system_message(format!("Error forking: {}", e));
}
}
}
return None;
}
OverlayAction::NavigateToEntry { entry_id } => {
state.overlay_state = None;
state.add_system_message(format!(
"Selected entry: {}",
&entry_id[..8.min(entry_id.len())]
));
return None;
}
_ => {}
}
return None;
}
let overlay = state.overlay.clone();
match &overlay {
Some(AppOverlay::Setup(_)) => handle_wizard_step_key(key, state, session).await,
Some(AppOverlay::ProviderConfig(_)) => handle_wizard_step_key(key, state, session).await,
Some(AppOverlay::ModelSelect { .. }) => handle_model_select_key(key, state, session).await,
Some(AppOverlay::LogoutSelect { .. }) => handle_logout_select_key(key, state).await,
Some(AppOverlay::ResumeSelect { .. }) => {
handle_resume_select_key(key, state, session).await
}
Some(AppOverlay::RoutingStatus { .. }) => None,
None => None,
}
}
fn extract_step(overlay: &Option<AppOverlay>) -> Option<&SetupStep> {
match overlay {
Some(AppOverlay::Setup(s)) | Some(AppOverlay::ProviderConfig(s)) => Some(s),
_ => None,
}
}
fn wrap_step(overlay: &Option<AppOverlay>, step: SetupStep) -> Option<AppOverlay> {
match overlay {
Some(AppOverlay::Setup(_)) => Some(AppOverlay::Setup(step)),
Some(AppOverlay::ProviderConfig(_)) => Some(AppOverlay::ProviderConfig(step)),
_ => None,
}
}
fn is_provider_config(overlay: &Option<AppOverlay>) -> bool {
matches!(overlay, Some(AppOverlay::ProviderConfig(_)))
}
fn build_provider_list(is_config: bool) -> Vec<ProviderInfo> {
let auth = oxi_store::auth_storage::shared_auth_storage();
let mut providers: Vec<ProviderInfo> = oxi_ai::register_builtins::get_builtin_providers()
.iter()
.map(|builtin| {
let has_key = if is_config {
auth.has_auth(builtin.name)
} else {
auth.get_api_key(builtin.name).is_some()
};
ProviderInfo {
name: builtin.name.to_string(),
display_name: builtin.display_name.to_string(),
has_key,
category: builtin.category.to_string(),
description: builtin.description.to_string(),
}
})
.collect();
let category_rank = |cat: &str| -> usize {
match cat {
"primary" => 0,
"chinese" => 1,
"open" => 2,
"cloud" => 3,
"enterprise" => 4,
"specialized" => 5,
_ => 6,
}
};
providers.sort_by(|a, b| {
category_rank(&a.category)
.cmp(&category_rank(&b.category))
.then_with(|| a.name.cmp(&b.name))
});
providers
}
async fn handle_wizard_step_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
session: &AgentSession,
) -> Option<Action> {
let step_kind = match extract_step(&state.overlay) {
Some(s) => match s {
SetupStep::SelectAuthType { .. } => 0,
SetupStep::SelectProvider { .. } => 1,
SetupStep::EnterApiKey { .. } => 2,
SetupStep::SelectModel { .. } => 3,
SetupStep::Done { .. } => 4,
},
_ => return None,
};
let is_config = is_provider_config(&state.overlay);
match step_kind {
0 => {
match key.code {
KeyCode::Up | KeyCode::Down => {
if let Some(SetupStep::SelectAuthType {
auth_type,
selected,
}) = extract_step(&state.overlay)
{
let new_sel = if *selected == 0 { 1 } else { 0 };
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectAuthType {
auth_type: auth_type.clone(),
selected: new_sel,
},
);
}
}
KeyCode::Enter => {
if let Some(SetupStep::SelectAuthType { selected, .. }) =
extract_step(&state.overlay)
{
match *selected {
0 => {
let providers = build_provider_list(is_config);
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectProvider {
providers,
selected: 0,
filter: String::new(),
},
);
}
1 => {
let providers = build_provider_list(is_config);
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectProvider {
providers,
selected: 0,
filter: String::new(),
},
);
}
_ => {}
}
}
}
KeyCode::Char('q') | KeyCode::Esc => {
state.overlay = None;
}
_ => {}
}
}
1 => {
match key.code {
KeyCode::Up => {
if let Some(SetupStep::SelectProvider {
providers,
selected,
..
}) = extract_step(&state.overlay)
{
let new_sel = if *selected == 0 {
providers.len() - 1
} else {
*selected - 1
};
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectProvider {
providers: providers.clone(),
selected: new_sel,
filter: String::new(),
},
);
}
}
KeyCode::Down => {
if let Some(SetupStep::SelectProvider {
providers,
selected,
..
}) = extract_step(&state.overlay)
{
let new_sel = (*selected + 1) % providers.len();
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectProvider {
providers: providers.clone(),
selected: new_sel,
filter: String::new(),
},
);
}
}
KeyCode::Enter => {
if let Some(SetupStep::SelectProvider {
providers,
selected,
..
}) = extract_step(&state.overlay)
{
if let Some(pi) = providers.get(*selected).cloned() {
state.overlay = wrap_step(
&state.overlay,
SetupStep::EnterApiKey {
provider: pi.name.clone(),
key: String::new(),
masked_cursor: 0,
},
);
}
}
}
KeyCode::Esc => {
state.overlay = None;
}
_ => {}
}
}
2 => {
let provider = match extract_step(&state.overlay) {
Some(SetupStep::EnterApiKey { provider, .. }) => provider.clone(),
_ => return None,
};
match key.code {
KeyCode::Char(c) => {
if let Some(SetupStep::EnterApiKey { key, .. }) =
extract_step_mut(&mut state.overlay)
{
key.push(c);
}
}
KeyCode::Backspace => {
if let Some(SetupStep::EnterApiKey { key, .. }) =
extract_step_mut(&mut state.overlay)
{
key.pop();
}
}
KeyCode::Enter => {
let key_val = match extract_step(&state.overlay) {
Some(SetupStep::EnterApiKey { key, .. }) => key.clone(),
_ => String::new(),
};
if !key_val.is_empty() {
let auth = oxi_store::auth_storage::shared_auth_storage();
auth.set_api_key(&provider, key_val);
let models: Vec<String> = oxi_ai::model_db::get_all_models()
.filter(|e| e.provider == provider)
.map(|e| e.id.to_string())
.collect();
if models.is_empty() {
if !is_config {
let model_id = "default".to_string();
let full_model = format!("{}/{}", provider, model_id);
if let Ok(mut settings) = oxi_store::settings::Settings::load() {
settings.default_model = Some(model_id.clone());
settings.default_provider = Some(provider.clone());
let _ = settings.save();
}
state.footer_state.data.model_name = full_model.clone();
state.footer_state.data.provider_name = provider.clone();
state.overlay = wrap_step(
&state.overlay,
SetupStep::Done {
provider: provider.clone(),
model: full_model,
},
);
} else {
state.add_system_message(format!("{} API key saved.", provider));
state.overlay = None;
}
} else {
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectModel {
provider,
models,
selected: 0,
},
);
}
}
}
KeyCode::Esc => {
let providers = build_provider_list(is_config);
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectProvider {
providers,
selected: 0,
filter: String::new(),
},
);
}
_ => {}
}
}
3 => {
match key.code {
KeyCode::Up => {
if let Some(SetupStep::SelectModel {
provider,
models,
selected,
}) = extract_step(&state.overlay)
{
let new_sel = if *selected == 0 {
models.len().saturating_sub(1)
} else {
*selected - 1
};
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectModel {
provider: provider.clone(),
models: models.clone(),
selected: new_sel,
},
);
}
}
KeyCode::Down => {
if let Some(SetupStep::SelectModel {
provider,
models,
selected,
}) = extract_step(&state.overlay)
{
let new_sel = if models.is_empty() {
0
} else {
(*selected + 1).min(models.len() - 1)
};
state.overlay = wrap_step(
&state.overlay,
SetupStep::SelectModel {
provider: provider.clone(),
models: models.clone(),
selected: new_sel,
},
);
}
}
KeyCode::Enter => {
if let Some(SetupStep::SelectModel {
provider,
models,
selected,
}) = extract_step(&state.overlay)
{
if let Some(model_id) = models.get(*selected) {
let full_model = format!("{}/{}", provider, model_id);
if let Ok(mut settings) = oxi_store::settings::Settings::load() {
settings.default_model = Some(model_id.to_string());
settings.default_provider = Some(provider.clone());
let _ = settings.save();
}
state.footer_state.data.model_name = full_model.clone();
state.footer_state.data.provider_name = provider.clone();
if !is_config {
state.overlay = wrap_step(
&state.overlay,
SetupStep::Done {
provider: provider.clone(),
model: full_model,
},
);
} else {
if let Err(e) = session.set_model(&full_model) {
state.add_system_message(format!("Error switching model: {}", e));
} else {
state.add_system_message(format!("Model set to {}", full_model));
}
state.overlay = None;
}
}
}
}
KeyCode::Esc => {
if let Some(SetupStep::SelectModel { provider, .. }) =
extract_step(&state.overlay)
{
state.overlay = wrap_step(
&state.overlay,
SetupStep::EnterApiKey {
provider: provider.clone(),
key: String::new(),
masked_cursor: 0,
},
);
}
}
_ => {}
}
}
4
if key.code == KeyCode::Enter => {
state.overlay = None;
state.add_system_message(" Ready to chat. Type a message to start.".to_string());
}
_ => {}
}
None
}
fn extract_step_mut(overlay: &mut Option<AppOverlay>) -> Option<&mut SetupStep> {
match overlay {
Some(AppOverlay::Setup(s)) | Some(AppOverlay::ProviderConfig(s)) => Some(s),
_ => None,
}
}
async fn handle_model_select_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
session: &AgentSession,
) -> Option<Action> {
let (models, filter, selected) = match &state.overlay {
Some(AppOverlay::ModelSelect {
models,
filter,
selected,
}) => (models.clone(), filter.clone(), *selected),
_ => return None,
};
let filtered: Vec<(usize, &String)> = if filter.is_empty() {
models.iter().enumerate().collect()
} else {
let lower = filter.to_lowercase();
models
.iter()
.enumerate()
.filter(|(_, m)| m.to_lowercase().contains(&lower))
.collect()
};
match key.code {
KeyCode::Up => {
let new_sel = if selected == 0 {
filtered.len().saturating_sub(1)
} else {
selected.saturating_sub(1)
};
state.overlay = Some(AppOverlay::ModelSelect {
models,
filter,
selected: new_sel,
});
}
KeyCode::Down => {
let new_sel = if filtered.is_empty() {
0
} else {
(selected + 1).min(filtered.len() - 1)
};
state.overlay = Some(AppOverlay::ModelSelect {
models,
filter,
selected: new_sel,
});
}
KeyCode::Enter => {
if let Some((_idx, model_id)) = filtered.get(selected) {
let model_id = (*model_id).clone();
match session.set_model(&model_id) {
Ok(()) => {
state.add_system_message(format!("Model: {}", model_id));
state.footer_state.data.model_name = model_id.clone();
oxi_store::settings::Settings::save_last_used(&model_id);
}
Err(e) => {
state.add_system_message(format!("Error: {}", e));
}
}
}
state.overlay = None;
}
KeyCode::Esc => {
state.overlay = None;
}
KeyCode::Backspace => {
let mut new_filter = filter;
new_filter.pop();
state.overlay = Some(AppOverlay::ModelSelect {
models,
filter: new_filter,
selected: 0,
});
}
KeyCode::Char(c) => {
let mut new_filter = filter;
new_filter.push(c);
state.overlay = Some(AppOverlay::ModelSelect {
models,
filter: new_filter,
selected: 0,
});
}
_ => {}
}
None
}
async fn handle_resume_select_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
_session: &crate::app::agent_session::AgentSession,
) -> Option<Action> {
let (sessions, selected) = match &state.overlay {
Some(AppOverlay::ResumeSelect { sessions, selected }) => (sessions.clone(), *selected),
_ => return None,
};
match key.code {
KeyCode::Up => {
let new_sel = if selected == 0 {
sessions.len().saturating_sub(1)
} else {
selected - 1
};
state.overlay = Some(AppOverlay::ResumeSelect {
sessions,
selected: new_sel,
});
}
KeyCode::Down => {
let new_sel = if sessions.is_empty() {
0
} else {
(selected + 1).min(sessions.len() - 1)
};
state.overlay = Some(AppOverlay::ResumeSelect {
sessions,
selected: new_sel,
});
}
KeyCode::Enter => {
if !state.input.text().is_empty() {
return None;
}
if let Some(session_info) = sessions.get(selected) {
state.next_action = Some(super::app::TuiNextAction::SwitchSession(
session_info.path.clone(),
));
state.add_system_message(format!("Switching to session: {}", session_info.path));
}
state.overlay = None;
}
KeyCode::Esc => {
state.overlay = None;
}
_ => {}
}
None
}
async fn handle_logout_select_key(
key: crossterm::event::KeyEvent,
state: &mut AppState,
) -> Option<Action> {
let (providers, selected) = match &state.overlay {
Some(AppOverlay::LogoutSelect {
providers,
selected,
}) => (providers.clone(), *selected),
_ => return None,
};
match key.code {
KeyCode::Up => {
let new_sel = if selected == 0 {
providers.len().saturating_sub(1)
} else {
selected - 1
};
state.overlay = Some(AppOverlay::LogoutSelect {
providers,
selected: new_sel,
});
}
KeyCode::Down => {
let new_sel = if providers.is_empty() {
0
} else {
(selected + 1).min(providers.len() - 1)
};
state.overlay = Some(AppOverlay::LogoutSelect {
providers,
selected: new_sel,
});
}
KeyCode::Enter => {
if let Some(provider) = providers.get(selected) {
let auth = oxi_store::auth_storage::shared_auth_storage();
auth.remove(provider);
state.add_system_message(format!("Removed {}", provider));
}
state.overlay = None;
}
KeyCode::Esc => {
state.overlay = None;
}
_ => {}
}
None
}
fn handle_overlay_paste(text: &str, state: &mut AppState) -> Option<Action> {
match &state.overlay {
Some(AppOverlay::Setup(SetupStep::EnterApiKey { .. }))
| Some(AppOverlay::ProviderConfig(SetupStep::EnterApiKey { .. })) => {
if let Some(SetupStep::EnterApiKey { key, .. }) = extract_step_mut(&mut state.overlay) {
key.push_str(text);
}
}
Some(AppOverlay::ModelSelect { .. }) => {
if let Some(AppOverlay::ModelSelect { filter, .. }) = &mut state.overlay {
filter.push_str(text);
}
}
_ => {}
}
None
}
fn handle_click(col: u16, row: u16, state: &mut AppState) {
let thinking: Vec<(u16, u16, String)> = state.chat.thinking_regions.clone();
for (y_start, y_end, key) in &thinking {
if row >= *y_start && row < *y_end {
state.chat.toggle_thinking(key);
return;
}
}
let tools: Vec<(u16, u16, String)> = state.chat.tool_regions.clone();
for (y_start, y_end, key) in &tools {
if row >= *y_start && row < *y_end {
state.chat.toggle_tool(key);
return;
}
}
let _ = (col, row); }
fn update_file_completions(state: &mut AppState) {
let text = state.input.text();
if text.starts_with("./")
|| text.starts_with("../")
|| text.starts_with('~')
|| (text.contains('/') && !text.starts_with('/'))
{
let results = state.completion_manager.get_completions(text.as_str());
state.file_completions = results;
state.file_completion_index = 0;
state.file_completion_active = !state.file_completions.is_empty();
} else if !text.starts_with('/') {
state.file_completions.clear();
state.file_completion_active = false;
}
}