use super::app::{AppState, NotificationKind, UiEvent};
use super::overlay::OverlayAction;
use super::overlay::router_integration;
use super::slash;
use std::sync::atomic::Ordering;
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(session);
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()
&& 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_
&& let oxi_tui::keybindings::keys::BaseKey::Char(c) = key_id.base
{
state.input.insert_char(c);
state.update_slash_completions(session);
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_notification(format!("Removed: {}", preview), NotificationKind::Info);
}
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_notification("Compaction cancelled".to_string(), NotificationKind::Info);
} else if state.is_agent_busy {
session.agent_ref().cancel();
session.should_stop_flag().store(true, Ordering::SeqCst);
state.cancel_streaming();
state.add_notification("Agent stopped".to_string(), NotificationKind::Info);
} 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(session);
update_file_completions(state);
None
}
KAction::DeleteCharForward => {
state.input.delete();
state.update_slash_completions(session);
update_file_completions(state);
None
}
KAction::DeleteToLineStart => {
state.input.delete_to_line_start();
state.update_slash_completions(session);
update_file_completions(state);
None
}
KAction::DeleteToLineEnd => {
state.input.delete_to_line_end();
state.update_slash_completions(session);
update_file_completions(state);
None
}
KAction::Undo => {
state.input.undo();
None
}
KAction::Tab => {
if state.slash_completion_active {
let sel = state
.selected_slash_command()
.map(|c| (c.name.clone(), c.is_arg));
state.clear_slash_completions();
if let Some((text, is_arg)) = sel {
if is_arg {
state.input_clear();
state.input_set_text(text);
} else {
state.input_clear();
return Some(Action::ExecuteSlashCommand(text));
}
}
}
None
}
KAction::CycleThinking => {
if !state.slash_completion_active
&& let Some(next_level) = session.cycle_thinking_level()
{
state.footer_state.data.thinking_level =
Some(format!("{:?}", next_level).to_lowercase());
state.add_notification(
format!("Thinking: {:?}", next_level),
NotificationKind::Info,
);
}
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_sdk::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_notification(
"Code block copied".to_string(),
NotificationKind::Success,
),
Err(e) => state
.add_notification(format!("Copy failed: {}", e), NotificationKind::Error),
}
} else {
state.add_notification(
"No code block to copy".to_string(),
NotificationKind::Warning,
);
}
None
}
KAction::DeleteWordBackward => {
state.input.delete_word_backward();
state.update_slash_completions(session);
update_file_completions(state);
None
}
KAction::DeleteWordForward => {
state.input.delete_word_forward();
state.update_slash_completions(session);
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 raw = state.input_value().to_string();
if raw.is_empty() {
return None;
}
let value = expand_issue_refs(&raw, &state.issue_store);
link_sessions_async(raw.clone(), state.issue_store.clone(), session);
if state.slash_completion_active {
let sel = state
.selected_slash_command()
.map(|c| (c.name.clone(), c.is_arg));
state.clear_slash_completions();
state.input_clear();
if let Some((text, _)) = sel {
return Some(Action::ExecuteSlashCommand(text));
}
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_notification(
format!("Queued: {}", value.chars().take(50).collect::<String>()),
NotificationKind::Info,
);
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,
session: &crate::app::agent_session::AgentSession,
) {
match event {
UiEvent::AgentStart => {
}
UiEvent::AgentEnd => {
state.is_agent_busy = false;
session.persist();
}
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);
session.persist_event_message(&message);
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_notification(format!("Error: {}", msg), NotificationKind::Error);
}
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_notification(format!("{} (Esc to cancel)", label), NotificationKind::Info);
}
UiEvent::CompactionEnd {
_reason,
error_message,
} => {
state.footer_state.data.is_compacting = false;
if let Some(err) = error_message {
state.add_notification(
format!("Compaction failed: {}", err),
NotificationKind::Error,
);
} else {
state.needs_chat_rebuild = true;
}
}
UiEvent::RetryStart {
attempt,
max_attempts,
error_message,
} => {
state.add_notification(
format!("Retry ({}/{}): {}", attempt, max_attempts, error_message),
NotificationKind::Warning,
);
}
UiEvent::ModelChanged { model_id } => {
state.add_notification(format!("Model: {}", model_id), NotificationKind::Success);
state.footer_state.data.model_name = model_id;
}
UiEvent::ThinkingLevelChanged { level } => {
state.add_notification(format!("Thinking: {}", level), NotificationKind::Info);
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_notification(msg, NotificationKind::Info);
}
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_notification(
"Opened image in viewer".to_string(),
NotificationKind::Success,
);
}
Err(e) => {
state.add_notification(
format!("Failed to write image: {}", e),
NotificationKind::Error,
);
}
}
}
Err(e) => {
state.add_notification(
format!("Failed to decode image: {}", e),
NotificationKind::Error,
);
}
}
} else {
state.add_notification(
"No images to display".to_string(),
NotificationKind::Warning,
);
}
}
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 {
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_sdk::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 = crate::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_notification(
format!("Forked from [{}]", &entry_id[..8.min(entry_id.len())]),
NotificationKind::Success,
);
}
Err(e) => {
state.add_notification(
format!("Error forking: {}", e),
NotificationKind::Error,
);
}
}
}
return None;
}
OverlayAction::NavigateToEntry { entry_id } => {
state.overlay_state = None;
state.next_action = Some(super::app::TuiNextAction::GotoEntry(entry_id));
return None;
}
OverlayAction::ProviderKeySaved { provider_name } => {
state.overlay_state = None;
let model_providers = resolve_sibling_providers(state, &provider_name);
let models: Vec<String> = list_models_for_providers(state, &model_providers);
if models.is_empty() {
let full_model = format!("{}/default", provider_name);
if let Ok(mut settings) = crate::store::settings::Settings::load() {
settings.last_used_provider = Some(provider_name.clone());
settings.last_used_model = Some("default".to_string());
let _ = settings.save();
}
if let Err(e) = session.set_model(&full_model) {
state.add_notification(
format!("Error setting model: {}", e),
NotificationKind::Error,
);
} else {
state.footer_state.data.model_name = full_model.clone();
state.footer_state.data.provider_name = provider_name.clone();
state.add_notification(
format!("Model: {}", full_model),
NotificationKind::Success,
);
}
return None;
}
state.overlay_state = Some(Box::new(
crate::tui::overlay::model_select_inline::ModelSelectInlineOverlay::new(
provider_name,
models,
),
));
return None;
}
OverlayAction::ModelSelected {
provider_name,
model_id,
} => {
state.overlay_state = None;
let full_model = format!("{}/{}", provider_name, model_id);
if let Ok(mut settings) = crate::store::settings::Settings::load() {
settings.last_used_provider = Some(provider_name.clone());
settings.last_used_model = Some(model_id.clone());
let _ = settings.save();
}
if let Err(e) = session.set_model(&full_model) {
state.add_notification(
format!("Error setting model: {}", e),
NotificationKind::Error,
);
} else {
state.footer_state.data.model_name = full_model.clone();
state.footer_state.data.provider_name = provider_name.clone();
state.add_notification(
format!("Model: {}", full_model),
NotificationKind::Success,
);
}
return None;
}
OverlayAction::McpAction(action) => {
use super::overlay::mcp_dashboard::McpAction as MA;
let manager = match session.agent_ref().tools().mcp_manager() {
Some(m) => m,
None => {
state.add_notification(
"MCP manager unavailable".into(),
NotificationKind::Error,
);
return None;
}
};
match action {
MA::Reconnect(server) => {
match manager.connect(&server).await {
Ok(_) => {
state.add_notification(
format!("MCP: reconnected {}", server),
NotificationKind::Success,
);
}
Err(e) => {
state.add_notification(
format!("MCP reconnect failed: {}", e),
NotificationKind::Error,
);
}
}
if let Some(ref mut o) = state.overlay_state {
o.mark_refresh();
}
}
MA::ReconnectAll => {
let names: Vec<String> = {
let config = manager.config();
config.mcp_servers.keys().cloned().collect()
};
for srv in &names {
let _ = manager.connect(srv).await;
}
if let Some(ref mut o) = state.overlay_state {
o.mark_refresh();
}
}
MA::Disconnect(_server) => {
state.add_notification(
"Disconnect not yet implemented".into(),
NotificationKind::Info,
);
}
MA::SetConsent {
name,
state: consent,
} => {
let state_name = format!("{:?}", consent);
if let Err(e) = manager.consent().decide(&name, consent) {
state.add_notification(
format!("Consent error: {}", e),
NotificationKind::Error,
);
} else {
state.add_notification(
format!("MCP consent: {} → {}", name, state_name),
NotificationKind::Success,
);
}
}
MA::Refresh => {
if let Some(ref mut o) = state.overlay_state {
o.mark_refresh();
}
}
}
return None;
}
OverlayAction::McpConfigApplied { config, message } => {
let manager = session.agent_ref().tools().mcp_manager();
if let Some(m) = manager {
m.replace_config(config);
state.add_notification(message, NotificationKind::Success);
} else {
state.add_notification(
"MCP config saved, but manager unavailable — restart to apply.".into(),
NotificationKind::Warning,
);
}
if let Some(ref mut o) = state.overlay_state {
o.mark_refresh();
}
return None;
}
_ => {}
}
return None;
}
None
}
fn handle_overlay_paste(text: &str, state: &mut AppState) -> Option<Action> {
if let Some(ref mut overlay) = state.overlay_state {
let action = overlay.handle_paste(text);
match action {
OverlayAction::None => {}
OverlayAction::Close => {
state.overlay_state = None;
state.overlay = None;
}
_ => {
}
}
}
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;
}
}
use crate::store::issues::FileIssueStore;
fn expand_issue_refs(input: &str, store: &Option<FileIssueStore>) -> String {
let Some(store) = store else {
return input.to_string();
};
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
let rest = &input[i..];
let Some(off) = rest.find("@issue-") else {
out.push_str(rest);
break;
};
let at_byte = i + off;
if at_byte > 0 && !prev_char_is_whitespace(input, at_byte) {
out.push_str(&rest[..off + 1]);
i = at_byte + 1;
continue;
}
let num_start = at_byte + "@issue-".len();
let mut num_end = num_start;
while num_end < input.len() && input.as_bytes()[num_end].is_ascii_digit() {
num_end += 1;
}
if num_end == num_start {
out.push_str(&rest[..off + "@issue-".len()]);
i = at_byte + "@issue-".len();
continue;
}
let id: u32 = match input[num_start..num_end].parse() {
Ok(n) => n,
Err(_) => {
out.push_str(&rest[..off + "@issue-".len() + (num_end - num_start)]);
i = num_end;
continue;
}
};
out.push_str(&rest[..off]);
match store.read(id) {
Ok((issue, _hash)) => {
let preview = first_line_preview(&issue);
out.push_str(&format!(
"[#{} {} ({} / {}): {}]",
issue.meta.id,
issue.meta.title,
issue.meta.status,
issue.meta.priority,
preview
));
}
Err(_) => {
out.push_str(&rest[..off + (num_end - at_byte)]);
}
}
i = num_end;
}
out
}
fn prev_char_is_whitespace(input: &str, byte_pos: usize) -> bool {
if byte_pos == 0 {
return true;
}
let bytes = input.as_bytes();
let mut start = byte_pos - 1;
while start > 0 && !is_char_boundary(bytes[start]) {
start -= 1;
}
let prev = &input[start..byte_pos];
prev.chars().next().is_some_and(char::is_whitespace)
}
fn is_char_boundary(b: u8) -> bool {
(b & 0b1100_0000) != 0b1000_0000
}
fn first_line_preview(issue: &crate::store::issues::Issue) -> String {
issue
.body
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(truncate_for_preview)
.unwrap_or_default()
}
fn truncate_for_preview(s: &str) -> String {
const MAX: usize = 80;
if s.chars().count() <= MAX {
s.to_string()
} else {
let truncated: String = s.chars().take(MAX).collect();
format!("{truncated}…")
}
}
fn link_sessions_async(raw: String, store: Option<FileIssueStore>, session: &AgentSession) {
let Some(store) = store else { return };
let ids: Vec<u32> = parse_issue_ids(&raw);
if ids.is_empty() {
return;
}
let session_id = session.session_id();
let session_id = if session_id.is_empty() {
"tui".to_string()
} else {
session_id
};
for id in ids {
let store = store.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
if let Ok((_issue, hash)) = store.read(id) {
let _ = store.link_session(id, &session_id, Some(hash)).await;
}
});
}
}
fn parse_issue_ids(raw: &str) -> Vec<u32> {
let mut out = Vec::new();
let bytes = raw.as_bytes();
let needle = b"@issue-";
let mut i = 0;
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] == needle && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
let num_start = i + needle.len();
let mut num_end = num_start;
while num_end < bytes.len() && bytes[num_end].is_ascii_digit() {
num_end += 1;
}
if num_end > num_start
&& let Ok(n) = raw[num_start..num_end].parse::<u32>()
{
out.push(n);
}
i = num_end.max(num_start);
} else {
i += 1;
}
}
out
}
pub(crate) fn resolve_sibling_providers(
state: &super::app::AppState,
provider_name: &str,
) -> Vec<String> {
if let Some(ref cat) = state.catalog {
if let Some(entry) = cat.get_provider_sync(provider_name) {
let env_key = entry.env_key;
if let Some(ek) = env_key {
return cat
.list_providers_sync()
.into_iter()
.filter(|pid| {
cat.get_provider_sync(pid)
.and_then(|p| p.env_key)
.as_deref()
== Some(ek.as_str())
})
.collect();
}
}
return vec![provider_name.to_string()];
}
oxi_sdk::get_builtin_provider(provider_name)
.map(|bp| {
oxi_sdk::get_builtin_providers()
.iter()
.filter(|p| p.env_key == bp.env_key)
.map(|p| p.name.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![provider_name.to_string()])
}
pub(crate) fn list_models_for_providers(
state: &super::app::AppState,
providers: &[String],
) -> Vec<String> {
if let Some(ref cat) = state.catalog {
let mut out = Vec::new();
for pid in providers {
out.extend(cat.list_models_sync(pid).into_iter().map(|m| m.model_id));
}
return out;
}
oxi_sdk::get_all_models()
.filter(|e| providers.iter().any(|p| p == e.provider))
.map(|e| e.id.to_string())
.collect()
}