use crate::spec_ai_tui_app::backend::BackendRequest;
use crate::spec_ai_tui_app::models::ChatMessage;
use crate::spec_ai_tui_app::state::{AppState, PanelFocus};
use anyhow::{Context, Result};
use image::codecs::png::PngEncoder;
use image::{ColorType, ImageEncoder};
use crate::spec_ai_core::agent::ImageAttachment;
use crate::spec_ai_core::agent::ProviderKind;
use crate::spec_ai_core::agent::{ModelProvider, create_provider};
use crate::spec_ai_core::config::ModelConfig;
use crate::spec_ai_tui::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::spec_ai_tui::widget::builtin::{EditorAction, Selection, SlashCommand};
use tokio::sync::mpsc::UnboundedSender;
pub fn handle_event(
event: Event,
state: &mut AppState,
backend_tx: &UnboundedSender<BackendRequest>,
) -> bool {
state.drain_backend_events();
if state.quit {
return false;
}
match &event {
Event::Key(key) => {
if event.is_quit() {
state.quit = true;
return false;
}
match state.focus {
PanelFocus::Input => handle_input_key(&event, key, state, backend_tx),
PanelFocus::Chat => handle_chat_key(key, state),
}
}
Event::Paste(_) if state.focus == PanelFocus::Input => {
let was_showing = state.editor.show_slash_menu;
if let EditorAction::Handled = state.editor.handle_event(&event) {
sync_slash_menu_visibility(state, was_showing);
}
}
Event::Tick => {
on_tick(state);
}
Event::Resize { .. } => {
state.drain_backend_events();
}
_ => {}
}
!state.quit
}
pub fn on_tick(state: &mut AppState) {
state.tick = state.tick.saturating_add(1);
state.drain_backend_events();
}
fn handle_chat_key(key: &KeyEvent, state: &mut AppState) {
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
if state.scroll_offset > 0 {
state.scroll_offset = state.scroll_offset.saturating_sub(1);
} else {
state.focus = PanelFocus::Input;
state.editor.focused = true;
}
}
KeyCode::Up | KeyCode::Char('k') => {
state.scroll_offset = state.scroll_offset.saturating_add(1);
}
KeyCode::PageUp => {
state.scroll_offset = state.scroll_offset.saturating_add(8);
}
KeyCode::PageDown => {
state.scroll_offset = state.scroll_offset.saturating_sub(8);
}
KeyCode::Tab => {
state.focus = PanelFocus::Input;
state.editor.focused = true;
}
_ => {}
}
}
fn handle_input_key(
event: &Event,
key: &KeyEvent,
state: &mut AppState,
backend_tx: &UnboundedSender<BackendRequest>,
) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('v') {
match read_clipboard_image() {
Ok(Some(image)) => {
state.pending_images.push(image);
state.status = format!(
"Status: added image from clipboard ({} pending)",
state.pending_images.len()
);
return;
}
Ok(None) => {}
Err(err) => {
state.status = format!("Status: clipboard image error: {}", err);
}
}
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
if let KeyCode::Char('l') = key.code {
state.messages.clear();
state.status = "Chat cleared".to_string();
state.scroll_offset = 0;
return;
}
}
let was_showing = state.editor.show_slash_menu;
match state.editor.handle_event(event) {
EditorAction::Handled => {
sync_slash_menu_visibility(state, was_showing);
}
EditorAction::Submit(text) => {
submit_text(state, backend_tx, text);
}
EditorAction::SlashCommand(cmd) => {
submit_text(state, backend_tx, format!("/{}", cmd));
}
EditorAction::SlashMenuNext => {
if complete_slash_command(state) {
return;
}
let count = filtered_command_count(state);
state.slash_menu.next(count);
}
EditorAction::SlashMenuPrev => {
let count = filtered_command_count(state);
state.slash_menu.prev(count);
}
EditorAction::Escape => {
state.editor.show_slash_menu = false;
state.editor.slash_query.clear();
state.slash_menu.hide();
}
EditorAction::Ignored => match key.code {
KeyCode::Up if !state.editor.show_slash_menu => {
state.focus = PanelFocus::Chat;
state.editor.focused = false;
}
KeyCode::Up if state.editor.show_slash_menu => {
let count = filtered_command_count(state);
state.slash_menu.prev(count);
}
KeyCode::Down if state.editor.show_slash_menu => {
let count = filtered_command_count(state);
state.slash_menu.next(count);
}
KeyCode::PageUp => {
state.scroll_offset = state.scroll_offset.saturating_add(5);
}
KeyCode::PageDown => {
state.scroll_offset = state.scroll_offset.saturating_sub(5);
}
KeyCode::Tab => {
state.focus = PanelFocus::Chat;
state.editor.focused = false;
}
_ => {}
},
}
}
fn submit_text(state: &mut AppState, backend_tx: &UnboundedSender<BackendRequest>, text: String) {
let trimmed = text.trim();
let has_images = !state.pending_images.is_empty();
if trimmed.is_empty() && !has_images {
return;
}
let mut payload = trimmed.to_string();
if payload.is_empty() {
payload = "[Image attachment]".to_string();
}
state.messages.push(ChatMessage::user(&payload));
state.scroll_offset = 0;
state.busy = true;
state.status = "Running command...".to_string();
state.last_submitted_text = Some(payload.clone());
state.editor.clear();
state.editor.show_slash_menu = false;
state.editor.slash_query.clear();
state.slash_menu.hide();
if backend_tx
.send(BackendRequest::Submit {
input: payload,
images: state.pending_images.clone(),
})
.is_err()
{
state.busy = false;
state.status = "Backend unavailable".to_string();
state.error = Some("Backend channel closed".to_string());
}
}
fn read_clipboard_image() -> Result<Option<ImageAttachment>> {
let mut clipboard = arboard::Clipboard::new().context("open clipboard")?;
let image = match clipboard.get_image() {
Ok(image) => image,
Err(_) => return Ok(None),
};
let width = image.width as u32;
let height = image.height as u32;
let bytes = image.bytes.into_owned();
let rgba = image::RgbaImage::from_raw(width, height, bytes)
.context("clipboard image had unexpected size")?;
let mut png_bytes = Vec::new();
let encoder = PngEncoder::new(&mut png_bytes);
encoder.write_image(rgba.as_raw(), width, height, ColorType::Rgba8.into())?;
Ok(Some(ImageAttachment {
mime: "image/png".to_string(),
data: png_bytes,
}))
}
fn sync_slash_menu_visibility(state: &mut AppState, was_showing: bool) {
if state.editor.show_slash_menu && !was_showing {
state.slash_menu.show();
} else if !state.editor.show_slash_menu && was_showing {
state.slash_menu.hide();
}
}
fn filtered_command_count(state: &AppState) -> usize {
slash_menu_commands(state).len()
}
pub fn selected_slash_command(state: &AppState) -> Option<SlashCommand> {
slash_menu_commands(state)
.get(state.slash_menu.selected_index())
.cloned()
}
pub fn complete_slash_command(state: &mut AppState) -> bool {
if let Some(cmd) = selected_slash_command(state) {
let text = format!("/{}", cmd.name);
state.editor.text = text.clone();
state.editor.selection = Selection::cursor(text.len());
state.editor.show_slash_menu = false;
state.editor.slash_query.clear();
state.slash_menu.hide();
state.status = format!("Prepared /{} (Enter to run, add args manually)", cmd.name);
true
} else {
false
}
}
pub fn slash_menu_commands(state: &AppState) -> Vec<SlashCommand> {
let query = state.editor.slash_query.as_str();
let (command, _) = split_slash_query(query);
if command.eq_ignore_ascii_case("model") {
let selected = state.slash_menu.selected_index();
let matching: Vec<SlashCommand> = completion_commands_for_model(state)
.into_iter()
.filter(|cmd| cmd.matches(query))
.collect();
let selected = selected.min(matching.len().saturating_sub(1));
matching
.into_iter()
.enumerate()
.map(|(idx, mut cmd)| {
if idx != selected {
cmd.description.clear();
}
cmd
})
.collect()
} else if command.eq_ignore_ascii_case("provider") {
completion_commands_for_provider()
.into_iter()
.filter(|cmd| cmd.matches(query))
.collect()
} else {
state
.slash_commands
.iter()
.filter(|cmd| cmd.matches(query))
.cloned()
.collect()
}
}
fn split_slash_query(query: &str) -> (&str, &str) {
query
.split_once(' ')
.map_or((query.trim(), ""), |(command, arg)| {
(command.trim(), arg.trim())
})
}
fn completion_commands_for_model(state: &AppState) -> Vec<SlashCommand> {
let provider = active_provider_label(state.active_model.as_deref());
let (_, model_name) = parse_provider_model(state.active_model.as_deref());
let model_hint = model_name.unwrap_or_else(|| "default-model".to_string());
list_provider_models(&provider, model_hint.as_str())
.into_iter()
.map(|model_name| {
SlashCommand::new(
format!("model {}", model_name),
"Switch to this model".to_string(),
)
})
.collect()
}
fn completion_commands_for_provider() -> Vec<SlashCommand> {
available_providers()
.into_iter()
.map(|provider| {
SlashCommand::new(
format!("provider {}", provider),
"Switch active model provider".to_string(),
)
})
.collect()
}
fn list_provider_models(provider: &str, model_name: &str) -> Vec<String> {
if provider.eq_ignore_ascii_case("lmstudio") {
if let Some(models) = list_lmstudio_models_from_endpoint() {
return models;
}
}
let config = ModelConfig {
provider: provider.to_string(),
model_name: Some(model_name.to_string()),
api_key_source: Some("spec-ai-tui-autocomplete".to_string()),
..ModelConfig::default()
};
create_provider(&config)
.map(|provider| provider.metadata().supported_models)
.unwrap_or_default()
}
fn list_lmstudio_models_from_endpoint() -> Option<Vec<String>> {
let endpoint =
std::env::var("LMSTUDIO_ENDPOINT").unwrap_or_else(|_| "http://localhost:1234".to_string());
let url = lmstudio_models_url(&endpoint);
let response = fetch_lmstudio_json(&url)?;
let mut models: Vec<String> = response
.get("data")?
.as_array()?
.iter()
.filter_map(|model| {
model
.get("id")
.and_then(|id| id.as_str())
.map(str::to_string)
})
.collect();
let mut seen = std::collections::HashSet::new();
models.retain(|model| seen.insert(model.clone()));
if models.is_empty() {
None
} else {
Some(models)
}
}
fn lmstudio_models_url(endpoint: &str) -> String {
let base = endpoint.trim().trim_end_matches('/');
if base.ends_with("/v1/models") {
base.to_string()
} else if base.ends_with("/v1") {
format!("{base}/models")
} else {
format!("{base}/v1/models")
}
}
fn fetch_lmstudio_json(url: &str) -> Option<serde_json::Value> {
let (host, port, path) = parse_http_url(url)?;
let addrs = std::net::ToSocketAddrs::to_socket_addrs(&(host.as_str(), port)).ok()?;
let timeout = std::time::Duration::from_millis(500);
let mut stream = None;
for addr in addrs {
if let Ok(candidate) = std::net::TcpStream::connect_timeout(&addr, timeout) {
stream = Some(candidate);
break;
}
}
let mut stream = stream?;
stream.set_read_timeout(Some(timeout)).ok()?;
stream.set_write_timeout(Some(timeout)).ok()?;
let request = format!(
"GET {path} HTTP/1.1\r\nHost: {host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n"
);
std::io::Write::write_all(&mut stream, request.as_bytes()).ok()?;
let mut raw = String::new();
std::io::Read::read_to_string(&mut stream, &mut raw).ok()?;
let status = raw.lines().next()?;
let status_code = status.split_whitespace().nth(1)?.parse::<u16>().ok()?;
if !(200..300).contains(&status_code) {
return None;
}
let (headers, body) = raw.split_once("\r\n\r\n")?;
let body = if headers
.lines()
.any(|line| line.eq_ignore_ascii_case("transfer-encoding: chunked"))
{
decode_chunked_body(body)?
} else {
body.to_string()
};
serde_json::from_str(&body).ok()
}
fn parse_http_url(url: &str) -> Option<(String, u16, String)> {
let rest = url.trim().strip_prefix("http://")?;
let (authority, path) = rest
.split_once('/')
.map(|(authority, path)| (authority, format!("/{path}")))
.unwrap_or_else(|| (rest, "/".to_string()));
let (host, port) = authority
.rsplit_once(':')
.and_then(|(host, port)| Some((host, port.parse::<u16>().ok()?)))
.unwrap_or((authority, 80));
if host.is_empty() {
None
} else {
Some((host.to_string(), port, path))
}
}
fn decode_chunked_body(body: &str) -> Option<String> {
let mut decoded = String::new();
let mut rest = body;
loop {
let (size_line, after_size) = rest.split_once("\r\n")?;
let size_hex = size_line.split(';').next()?.trim();
let size = usize::from_str_radix(size_hex, 16).ok()?;
if size == 0 {
return Some(decoded);
}
if after_size.len() < size + 2 {
return None;
}
decoded.push_str(after_size.get(..size)?);
rest = after_size.get(size + 2..)?;
}
}
fn parse_provider_model(active_model: Option<&str>) -> (Option<String>, Option<String>) {
active_model
.map(|value| {
if let Some((provider, model_name)) = value.split_once('/') {
(Some(provider.to_string()), Some(model_name.to_string()))
} else {
(Some(value.to_string()), None)
}
})
.unwrap_or_else(|| (None, None))
}
fn active_provider_label(active_model: Option<&str>) -> String {
parse_provider_model(active_model)
.0
.or_else(|| Some("mock".to_string()))
.filter(|provider| !provider.is_empty())
.unwrap_or_else(|| "mock".to_string())
}
fn available_providers() -> Vec<&'static str> {
["mock", "openai", "anthropic", "ollama", "mlx", "lmstudio"]
.into_iter()
.filter(|provider| ProviderKind::from_str(provider).is_some())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_state() -> AppState {
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
AppState::new(rx)
}
fn create_backend_channel() -> UnboundedSender<BackendRequest> {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
tx
}
#[test]
fn on_tick_increments_tick_counter() {
let mut state = create_test_state();
assert_eq!(state.tick, 0);
on_tick(&mut state);
assert_eq!(state.tick, 1);
on_tick(&mut state);
assert_eq!(state.tick, 2);
}
#[test]
fn on_tick_saturates_at_max() {
let mut state = create_test_state();
state.tick = u64::MAX;
on_tick(&mut state);
assert_eq!(state.tick, u64::MAX);
}
#[test]
fn filtered_command_count_returns_all_when_empty_query() {
let state = create_test_state();
let total_commands = state.slash_commands.len();
let count = filtered_command_count(&state);
assert_eq!(count, total_commands);
}
#[test]
fn filtered_command_count_filters_by_query() {
let mut state = create_test_state();
state.editor.slash_query = "help".to_string();
let count = filtered_command_count(&state);
assert_eq!(count, 1);
}
#[test]
fn filtered_command_count_returns_zero_for_no_match() {
let mut state = create_test_state();
state.editor.slash_query = "zzzznonexistent".to_string();
let count = filtered_command_count(&state);
assert_eq!(count, 0);
}
#[test]
fn selected_slash_command_returns_first_when_index_zero() {
let mut state = create_test_state();
state.slash_menu.show();
let cmd = selected_slash_command(&state);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().name, "help");
}
#[test]
fn selected_slash_command_returns_none_for_empty_filter() {
let mut state = create_test_state();
state.editor.slash_query = "zzzznonexistent".to_string();
let cmd = selected_slash_command(&state);
assert!(cmd.is_none());
}
#[test]
fn selected_slash_command_respects_filter() {
let mut state = create_test_state();
state.editor.slash_query = "conf".to_string();
let cmd = selected_slash_command(&state);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().name, "config");
}
#[test]
fn selected_slash_command_returns_model_completion() {
let mut state = create_test_state();
state.active_model = Some("mock/mock-model".to_string());
state.editor.show_slash_menu = true;
state.editor.slash_query = "model mo".to_string();
let cmd = selected_slash_command(&state);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().name, "model mock-model");
}
#[test]
fn selected_slash_command_returns_provider_completion() {
let mut state = create_test_state();
state.editor.show_slash_menu = true;
state.editor.slash_query = "provider mock".to_string();
let cmd = selected_slash_command(&state);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().name, "provider mock");
}
#[test]
fn complete_slash_command_prefills_model_argument() {
let mut state = create_test_state();
state.active_model = Some("mock/mock-model".to_string());
state.editor.show_slash_menu = true;
state.editor.slash_query = "model mock".to_string();
let result = complete_slash_command(&mut state);
assert!(result);
assert_eq!(state.editor.text, "/model mock-model");
}
#[test]
fn complete_slash_command_prefills_provider_argument() {
let mut state = create_test_state();
state.editor.show_slash_menu = true;
state.editor.slash_query = "provider mock".to_string();
let result = complete_slash_command(&mut state);
assert!(result);
assert_eq!(state.editor.text, "/provider mock");
}
#[test]
fn complete_slash_command_returns_false_when_no_match() {
let mut state = create_test_state();
state.editor.slash_query = "zzzznonexistent".to_string();
let result = complete_slash_command(&mut state);
assert!(!result);
}
#[test]
fn complete_slash_command_sets_editor_text() {
let mut state = create_test_state();
state.editor.slash_query = "help".to_string();
let result = complete_slash_command(&mut state);
assert!(result);
assert_eq!(state.editor.text, "/help");
}
#[test]
fn complete_slash_command_hides_menu() {
let mut state = create_test_state();
state.editor.show_slash_menu = true;
state.slash_menu.show();
state.editor.slash_query = "help".to_string();
complete_slash_command(&mut state);
assert!(!state.editor.show_slash_menu);
}
#[test]
fn complete_slash_command_clears_query() {
let mut state = create_test_state();
state.editor.slash_query = "help".to_string();
complete_slash_command(&mut state);
assert!(state.editor.slash_query.is_empty());
}
#[test]
fn complete_slash_command_updates_status() {
let mut state = create_test_state();
state.editor.slash_query = "help".to_string();
complete_slash_command(&mut state);
assert!(state.status.contains("Prepared /help"));
}
#[test]
fn sync_slash_menu_visibility_shows_menu() {
let mut state = create_test_state();
state.editor.show_slash_menu = true;
sync_slash_menu_visibility(&mut state, false);
assert!(state.slash_menu.visible);
}
#[test]
fn sync_slash_menu_visibility_hides_menu() {
let mut state = create_test_state();
state.slash_menu.show();
state.editor.show_slash_menu = false;
sync_slash_menu_visibility(&mut state, true);
assert!(!state.slash_menu.visible);
}
#[test]
fn sync_slash_menu_visibility_no_change_when_same() {
let mut state = create_test_state();
state.editor.show_slash_menu = true;
sync_slash_menu_visibility(&mut state, true);
}
#[test]
fn handle_chat_key_down_decrements_scroll() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 5;
let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 4);
}
#[test]
fn handle_chat_key_down_switches_to_input_when_at_bottom() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 0;
let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.focus, PanelFocus::Input);
assert!(state.editor.focused);
}
#[test]
fn handle_chat_key_up_increments_scroll() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 5;
let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 6);
}
#[test]
fn handle_chat_key_j_acts_like_down() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 5;
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 4);
}
#[test]
fn handle_chat_key_k_acts_like_up() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 5;
let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 6);
}
#[test]
fn handle_chat_key_page_up_scrolls_by_8() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 5;
let key = KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 13);
}
#[test]
fn handle_chat_key_page_down_scrolls_by_8() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 10;
let key = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 2);
}
#[test]
fn handle_chat_key_tab_switches_to_input() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.editor.focused = false;
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.focus, PanelFocus::Input);
assert!(state.editor.focused);
}
#[test]
fn handle_chat_key_scroll_saturates_at_zero() {
let mut state = create_test_state();
state.focus = PanelFocus::Chat;
state.scroll_offset = 2;
let key = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE);
handle_chat_key(&key, &mut state);
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn handle_event_returns_false_on_quit() {
let mut state = create_test_state();
state.quit = true;
let backend_tx = create_backend_channel();
let result = handle_event(Event::Tick, &mut state, &backend_tx);
assert!(!result);
}
#[test]
fn handle_event_tick_increments_counter() {
let mut state = create_test_state();
let backend_tx = create_backend_channel();
assert_eq!(state.tick, 0);
handle_event(Event::Tick, &mut state, &backend_tx);
assert_eq!(state.tick, 1);
}
}