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_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 {
state
.slash_commands
.iter()
.filter(|cmd| cmd.matches(&state.editor.slash_query))
.count()
}
pub fn selected_slash_command(state: &AppState) -> Option<SlashCommand> {
let filtered: Vec<_> = state
.slash_commands
.iter()
.filter(|c| c.matches(&state.editor.slash_query))
.cloned()
.collect();
filtered.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
}
}
#[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 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);
}
}