use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crossterm::event::{Event, KeyEvent, KeyEventKind};
use ratatui::Terminal;
use ratatui::backend::Backend;
use tokio::sync::mpsc;
use crate::app::{App, AppEvent};
use crate::runtime::{EventResult, FRAME_INTERVAL, key_handler, mode};
use crate::ui::state::app_mode::AppMode;
#[cfg_attr(test, mockall::automock)]
pub(crate) trait EventSource: Send + Sync + 'static {
fn poll(&self, timeout: Duration) -> io::Result<bool>;
fn read(&self) -> io::Result<Event>;
}
struct CrosstermEventSource;
impl EventSource for CrosstermEventSource {
fn poll(&self, timeout: Duration) -> io::Result<bool> {
crossterm::event::poll(timeout)
}
fn read(&self) -> io::Result<Event> {
crossterm::event::read()
}
}
enum LoopSignal {
AppEvent(Box<Option<AppEvent>>),
Event(Option<Event>),
Tick,
}
pub(crate) fn spawn_event_reader(
event_tx: mpsc::UnboundedSender<Event>,
shutdown: Arc<AtomicBool>,
) -> std::thread::JoinHandle<()> {
let event_source: Arc<dyn EventSource> = Arc::new(CrosstermEventSource);
spawn_event_reader_with_source(event_source, event_tx, shutdown)
}
fn spawn_event_reader_with_source(
event_source: Arc<dyn EventSource>,
event_tx: mpsc::UnboundedSender<Event>,
shutdown: Arc<AtomicBool>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
loop {
if shutdown.load(Ordering::Relaxed) {
break;
}
match event_source.poll(FRAME_INTERVAL) {
Ok(true) => {
if let Ok(event) = event_source.read()
&& event_tx.send(event).is_err()
{
break;
}
}
Ok(false) => {}
Err(_) => break,
}
}
})
}
pub(crate) async fn process_events<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
event_rx: &mut mpsc::UnboundedReceiver<Event>,
tick: &mut tokio::time::Interval,
) -> io::Result<EventResult>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
process_events_with_handler(app, terminal, event_rx, tick, |app, terminal, event| {
Box::pin(process_event(app, terminal, event))
})
.await
}
async fn process_events_with_handler<Terminal, EventHandler>(
app: &mut App,
terminal: &mut Terminal,
event_rx: &mut mpsc::UnboundedReceiver<Event>,
tick: &mut tokio::time::Interval,
mut handle_event: EventHandler,
) -> io::Result<EventResult>
where
EventHandler: for<'handler> FnMut(
&'handler mut App,
&'handler mut Terminal,
Option<Event>,
) -> Pin<
Box<dyn Future<Output = io::Result<EventResult>> + 'handler>,
>,
{
let signal = tokio::select! {
biased;
event = event_rx.recv() => LoopSignal::Event(event),
app_event = app.next_app_event() => LoopSignal::AppEvent(Box::new(app_event)),
_ = tick.tick() => LoopSignal::Tick,
};
let maybe_event = match signal {
LoopSignal::AppEvent(app_event) => {
if let Some(event) = *app_event {
app.apply_app_events(event).await;
}
None
}
LoopSignal::Event(event) => event,
LoopSignal::Tick => {
if app.refresh_sessions_if_needed().await {
app.mark_dirty();
}
None
}
};
if matches!(
handle_event(app, terminal, maybe_event).await?,
EventResult::Quit
) {
return Ok(EventResult::Quit);
}
while let Ok(event) = event_rx.try_recv() {
if matches!(
handle_event(app, terminal, Some(event)).await?,
EventResult::Quit
) {
return Ok(EventResult::Quit);
}
}
Ok(EventResult::Continue)
}
async fn process_event<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
event: Option<Event>,
) -> io::Result<EventResult>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
process_event_with_key_handler(app, terminal, event, |app, terminal, key| {
Box::pin(key_handler::handle_key_event(app, terminal, key))
})
.await
}
async fn process_event_with_key_handler<Terminal, KeyHandler>(
app: &mut App,
terminal: &mut Terminal,
event: Option<Event>,
mut handle_key_event: KeyHandler,
) -> io::Result<EventResult>
where
KeyHandler: for<'handler> FnMut(
&'handler mut App,
&'handler mut Terminal,
KeyEvent,
) -> Pin<
Box<dyn Future<Output = io::Result<EventResult>> + 'handler>,
>,
{
if let Some(event) = event {
match event {
Event::Key(key) if is_press_key_event(key) => {
return handle_key_event(app, terminal, key).await;
}
Event::Paste(pasted_text) => {
process_paste_event(app, &pasted_text);
app.mark_dirty();
}
_ => {}
}
}
Ok(EventResult::Continue)
}
fn is_press_key_event(key: KeyEvent) -> bool {
key.kind == KeyEventKind::Press
}
fn process_paste_event(app: &mut App, pasted_text: &str) {
if matches!(&app.mode, AppMode::Prompt { .. }) {
mode::prompt::handle_paste(app, pasted_text);
}
if matches!(&app.mode, AppMode::Question { .. }) {
mode::question::handle_paste(app, pasted_text);
}
}
#[cfg(test)]
mod tests {
use std::io::ErrorKind;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use mockall::Sequence;
use mockall::predicate::eq;
use tempfile::tempdir;
use super::*;
use crate::db::Database;
use crate::domain::agent::AgentKind;
use crate::domain::input::InputState;
use crate::domain::question::QuestionItem;
use crate::domain::session::{Session, SessionSize, SessionStats, Status};
use crate::ui::state::app_mode::{AppMode, QuestionFocus};
use crate::ui::state::prompt::{PromptAttachmentState, PromptHistoryState, PromptSlashState};
fn test_app_clients() -> crate::app::AppClients {
crate::app::AppClients::new().with_agent_availability_probe(std::sync::Arc::new(
crate::infra::agent::StaticAgentAvailabilityProbe {
available_agent_kinds: AgentKind::ALL.to_vec(),
},
))
}
async fn new_test_app() -> App {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app")
}
#[tokio::test]
async fn test_spawn_event_reader_with_source_forwards_event_to_channel() {
let mut mock_source = MockEventSource::new();
let mut sequence = Sequence::new();
mock_source
.expect_poll()
.with(eq(FRAME_INTERVAL))
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Ok(true));
mock_source
.expect_read()
.times(1)
.in_sequence(&mut sequence)
.returning(|| {
Ok(Event::Key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
)))
});
mock_source
.expect_poll()
.with(eq(FRAME_INTERVAL))
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Err(io::Error::new(ErrorKind::Interrupted, "stop")));
let event_source: Arc<dyn EventSource> = Arc::new(mock_source);
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let shutdown = Arc::new(AtomicBool::new(false));
let join_handle = spawn_event_reader_with_source(event_source, event_tx, shutdown);
let received_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
.expect("timed out waiting for event")
.expect("failed to receive event");
join_handle
.join()
.expect("failed to join event reader thread");
assert!(matches!(received_event, Event::Key(_)));
}
#[test]
fn test_spawn_event_reader_with_source_stops_when_receiver_is_dropped() {
let mut mock_source = MockEventSource::new();
mock_source
.expect_poll()
.with(eq(FRAME_INTERVAL))
.times(1)
.returning(|_| Ok(true));
mock_source.expect_read().times(1).returning(|| {
Ok(Event::Key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
)))
});
let event_source: Arc<dyn EventSource> = Arc::new(mock_source);
let (event_tx, event_rx) = mpsc::unbounded_channel();
drop(event_rx);
let shutdown = Arc::new(AtomicBool::new(false));
let join_handle = spawn_event_reader_with_source(event_source, event_tx, shutdown);
let join_result = join_handle.join();
assert!(join_result.is_ok());
}
#[test]
fn test_spawn_event_reader_with_source_skips_read_when_poll_returns_false() {
let mut mock_source = MockEventSource::new();
let mut sequence = Sequence::new();
mock_source
.expect_poll()
.with(eq(FRAME_INTERVAL))
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Ok(false));
mock_source
.expect_poll()
.with(eq(FRAME_INTERVAL))
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Err(io::Error::new(ErrorKind::Interrupted, "stop")));
mock_source.expect_read().times(0);
let event_source: Arc<dyn EventSource> = Arc::new(mock_source);
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let shutdown = Arc::new(AtomicBool::new(false));
let join_handle = spawn_event_reader_with_source(event_source, event_tx, shutdown);
let join_result = join_handle.join();
let queued_event = event_rx.try_recv();
assert!(join_result.is_ok());
assert!(queued_event.is_err());
}
#[test]
fn test_spawn_event_reader_with_source_exits_when_shutdown_is_already_requested() {
let mut mock_source = MockEventSource::new();
mock_source.expect_poll().times(0);
mock_source.expect_read().times(0);
let event_source: Arc<dyn EventSource> = Arc::new(mock_source);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let shutdown = Arc::new(AtomicBool::new(true));
let join_handle = spawn_event_reader_with_source(event_source, event_tx, shutdown);
let join_result = join_handle.join();
assert!(join_result.is_ok());
}
#[tokio::test]
async fn test_process_event_with_key_handler_pastes_into_prompt_mode() {
let mut app = new_test_app().await;
let session_id = "session-1".to_string();
app.sessions.push_session(Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: std::env::temp_dir(),
follow_up_tasks: Vec::new(),
id: session_id.clone().into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model: AgentKind::Antigravity.default_model(),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Draft,
summary: None,
title: None,
updated_at: 0,
workflow_notice: None,
});
app.mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::default(),
input: InputState::default(),
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
slash_state: PromptSlashState::default(),
};
let mut terminal = ();
let result = process_event_with_key_handler(
&mut app,
&mut terminal,
Some(Event::Paste("line 1\r\nline 2".to_string())),
|_, (), _| Box::pin(async { Err(io::Error::other("unexpected key-handler call")) }),
)
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
assert!(
matches!(&app.mode, AppMode::Prompt { input, .. } if input.text() == "line 1\nline 2")
);
}
#[tokio::test]
async fn test_process_event_with_key_handler_pastes_into_question_free_text_mode() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
review_status_message: None,
review_text: None,
scroll_offset: None,
questions: vec![QuestionItem {
options: vec!["yes".to_string()],
text: "Is this enough?".to_string(),
}],
responses: Vec::new(),
selected_option_index: None,
session_id: "session-1".into(),
};
let mut terminal = ();
let result = process_event_with_key_handler(
&mut app,
&mut terminal,
Some(Event::Paste("custom\ranswer".to_string())),
|_, (), _| Box::pin(async { Err(io::Error::other("unexpected key-handler call")) }),
)
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
assert!(matches!(
&app.mode,
AppMode::Question {
input,
selected_option_index: None,
..
} if input.text() == "custom\nanswer"
));
}
#[tokio::test]
async fn test_process_event_with_key_handler_ignores_resize_events() {
let mut app = new_test_app().await;
let original_mode = AppMode::List;
app.mode = original_mode;
let mut terminal = ();
let result = process_event_with_key_handler(
&mut app,
&mut terminal,
Some(Event::Resize(120, 40)),
|_, (), _| Box::pin(async { Err(io::Error::other("unexpected key-handler call")) }),
)
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
assert!(matches!(&app.mode, AppMode::List));
}
#[tokio::test]
async fn test_process_event_with_key_handler_ignores_key_release_events() {
let mut app = new_test_app().await;
let mut terminal = ();
let result = process_event_with_key_handler(
&mut app,
&mut terminal,
Some(Event::Key(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::ALT,
KeyEventKind::Release,
))),
|_, (), _| Box::pin(async { Err(io::Error::other("unexpected key-handler call")) }),
)
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
}
#[tokio::test]
async fn test_process_events_with_handler_returns_handler_error() {
let mut app = new_test_app().await;
let mut terminal = ();
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
event_tx
.send(Event::Key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
)))
.expect("failed to queue event");
let mut tick = tokio::time::interval(Duration::from_mins(1));
let result = process_events_with_handler(
&mut app,
&mut terminal,
&mut event_rx,
&mut tick,
|_, (), _| Box::pin(async { Err(io::Error::other("handler failed")) }),
)
.await;
assert!(result.is_err());
let error = result
.err()
.expect("handler error should exit the event loop");
assert_eq!(error.to_string(), "handler failed");
}
}