use std::time::Duration;
use crossterm::event::{
Event as CrosstermEvent, EventStream, KeyCode, KeyModifiers, MouseEventKind,
};
use futures::StreamExt;
use tokio::sync::mpsc;
use super::AppEvent;
use crate::subprocess::StreamEvent;
#[derive(Debug, Clone)]
pub enum SubprocessEvent {
Output(String),
ToolUse { tool_name: String, content: String },
Usage(f64),
TokenUsage {
input_tokens: u64,
output_tokens: u64,
cache_creation_input_tokens: u64,
cache_read_input_tokens: u64,
},
IterationStart { iteration: u32 },
IterationDone { tasks_done: u32 },
Log(String),
StreamEvent(StreamEvent),
}
impl From<SubprocessEvent> for AppEvent {
fn from(event: SubprocessEvent) -> Self {
match event {
SubprocessEvent::Output(s) => AppEvent::ClaudeOutput(s),
SubprocessEvent::ToolUse { tool_name, content } => {
AppEvent::ToolMessage { tool_name, content }
}
SubprocessEvent::Usage(ratio) => AppEvent::ContextUsage(ratio),
SubprocessEvent::TokenUsage {
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
} => AppEvent::TokenUsage {
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
},
SubprocessEvent::IterationStart { iteration } => AppEvent::IterationStart { iteration },
SubprocessEvent::IterationDone { tasks_done } => {
AppEvent::IterationComplete { tasks_done }
}
SubprocessEvent::Log(s) => AppEvent::LogMessage(s),
SubprocessEvent::StreamEvent(e) => AppEvent::StreamEvent(e),
}
}
}
pub struct EventHandler {
rx: mpsc::UnboundedReceiver<AppEvent>,
_task: tokio::task::JoinHandle<()>,
}
impl EventHandler {
pub fn new(frame_rate: u32) -> (Self, mpsc::UnboundedSender<SubprocessEvent>) {
let (subprocess_tx, subprocess_rx) = mpsc::unbounded_channel();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let task = tokio::spawn(Self::event_loop(event_tx, subprocess_rx, frame_rate));
(
Self {
rx: event_rx,
_task: task,
},
subprocess_tx,
)
}
async fn event_loop(
tx: mpsc::UnboundedSender<AppEvent>,
mut subprocess_rx: mpsc::UnboundedReceiver<SubprocessEvent>,
frame_rate: u32,
) {
let mut event_stream = EventStream::new();
let tick_duration = Duration::from_millis(1000 / frame_rate as u64);
let mut render_interval = tokio::time::interval(tick_duration);
loop {
tokio::select! {
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(event)) => {
if let Some(app_event) = Self::convert_crossterm_event(event) {
if tx.send(app_event).is_err() {
break;
}
}
}
Some(Err(_)) => {
}
None => {
break;
}
}
}
maybe_subprocess = subprocess_rx.recv() => {
match maybe_subprocess {
Some(event) => {
if tx.send(AppEvent::from(event)).is_err() {
break;
}
}
None => {
}
}
}
_ = render_interval.tick() => {
if tx.send(AppEvent::Render).is_err() {
break;
}
}
}
}
}
fn convert_crossterm_event(event: CrosstermEvent) -> Option<AppEvent> {
match event {
CrosstermEvent::Key(key) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
if let KeyCode::Char('c') = key.code {
return Some(AppEvent::Quit);
}
}
if let KeyCode::BackTab = key.code {
return Some(AppEvent::SelectPrevMessage);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => Some(AppEvent::ScrollDown),
KeyCode::Char('k') | KeyCode::Up => Some(AppEvent::ScrollUp),
KeyCode::Char('{') => Some(AppEvent::PrevIteration),
KeyCode::Char('}') => Some(AppEvent::NextIteration),
KeyCode::Char('p') => Some(AppEvent::TogglePause),
KeyCode::Char('q') => Some(AppEvent::Quit),
KeyCode::Char('c') => Some(AppEvent::ToggleConversation),
KeyCode::Char('t') => Some(AppEvent::ToggleThinkingCollapse),
KeyCode::PageUp => Some(AppEvent::ConversationScrollUp(10)),
KeyCode::PageDown => Some(AppEvent::ConversationScrollDown(10)),
KeyCode::Esc => Some(AppEvent::Quit),
KeyCode::Tab => Some(AppEvent::SelectNextMessage),
KeyCode::Enter | KeyCode::Char(' ') => Some(AppEvent::ToggleMessage),
_ => None,
}
}
CrosstermEvent::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => Some(AppEvent::ScrollUp),
MouseEventKind::ScrollDown => Some(AppEvent::ScrollDown),
_ => None,
},
CrosstermEvent::Resize(_, _) => {
Some(AppEvent::Render)
}
_ => None,
}
}
pub async fn next(&mut self) -> Option<AppEvent> {
self.rx.recv().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subprocess_event_into_app_event() {
let output = SubprocessEvent::Output("hello".to_string());
let app_event: AppEvent = output.into();
assert!(matches!(app_event, AppEvent::ClaudeOutput(s) if s == "hello"));
let tool = SubprocessEvent::ToolUse {
tool_name: "Read".to_string(),
content: "file contents".to_string(),
};
let app_event: AppEvent = tool.into();
assert!(matches!(
app_event,
AppEvent::ToolMessage { tool_name, content }
if tool_name == "Read" && content == "file contents"
));
let usage = SubprocessEvent::Usage(0.75);
let app_event: AppEvent = usage.into();
assert!(matches!(app_event, AppEvent::ContextUsage(r) if (r - 0.75).abs() < f64::EPSILON));
let token_usage = SubprocessEvent::TokenUsage {
input_tokens: 5000,
output_tokens: 1500,
cache_creation_input_tokens: 2000,
cache_read_input_tokens: 1000,
};
let app_event: AppEvent = token_usage.into();
assert!(matches!(
app_event,
AppEvent::TokenUsage {
input_tokens: 5000,
output_tokens: 1500,
cache_creation_input_tokens: 2000,
cache_read_input_tokens: 1000,
}
));
let done = SubprocessEvent::IterationDone { tasks_done: 5 };
let app_event: AppEvent = done.into();
assert!(matches!(
app_event,
AppEvent::IterationComplete { tasks_done: 5 }
));
let start = SubprocessEvent::IterationStart { iteration: 3 };
let app_event: AppEvent = start.into();
assert!(matches!(
app_event,
AppEvent::IterationStart { iteration: 3 }
));
let log = SubprocessEvent::Log("log message".to_string());
let app_event: AppEvent = log.into();
assert!(matches!(app_event, AppEvent::LogMessage(s) if s == "log message"));
}
#[test]
fn test_subprocess_stream_event_conversion() {
use crate::subprocess::StreamEvent;
let json = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"#;
let stream_event = StreamEvent::parse(json).unwrap();
let subprocess_event = SubprocessEvent::StreamEvent(stream_event);
let app_event: AppEvent = subprocess_event.into();
assert!(matches!(app_event, AppEvent::StreamEvent(_)));
}
}