use crate::{
core::conversation::ConversationItem,
session::{SessionMessage, TokenUsage},
ui::{
conversation::{ConversationView, ConversationViewModel, HighlightConfig},
statusline::{InputMode, StatusBar},
},
};
use crossterm::event::EventStream;
use futures::StreamExt;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
Frame, Terminal,
layout::{Constraint, Layout},
prelude::CrosstermBackend,
};
use ratatui_textarea::TextArea;
use std::io::Stdout;
use tokio::{
sync::mpsc::{Receiver, Sender},
time::{Duration, interval},
};
pub mod conversation;
pub mod statusline;
#[derive(Clone, Debug)]
pub enum UiMessage {
NewConversationItem(ConversationItem),
UpdatedConversationItem(ConversationItem),
GenerationFinished(TokenUsage),
}
#[derive(Debug)]
pub struct Ui {
conversation: ConversationViewModel, input: TextArea<'static>,
status: StatusBar<'static>,
session_tx: Sender<SessionMessage>,
rx: Receiver<UiMessage>,
dirty: bool,
}
impl Ui {
pub fn new(session_tx: Sender<SessionMessage>, rx: Receiver<UiMessage>) -> Self {
Ui {
conversation: ConversationViewModel::new(HighlightConfig::new()),
input: TextArea::default(),
status: StatusBar {
input_mode: InputMode::Insert,
model: "Qwen3-32B",
context_used: 41_287,
context_total: 65_536,
tokens: TokenUsage::default(),
assistant_mode: "Code",
},
session_tx,
rx,
dirty: true,
}
}
pub async fn run(&mut self, mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
let mut input_events = EventStream::new();
let mut redraw_tick = interval(Duration::from_millis(20));
loop {
tokio::select! {
Some(Ok(event)) = input_events.next() => {
if let Event::Key(key) = event {
if key.code == KeyCode::Esc {
self.session_tx.send(SessionMessage::QuitRequest).await.unwrap();
break Ok(());
} else if key.code == KeyCode::Up {
self.conversation.move_line_up();
} else if key.code == KeyCode::Down {
self.conversation.move_line_down();
} else if key.code == KeyCode::PageUp {
self.conversation.move_page_up();
} else if key.code == KeyCode::PageDown {
self.conversation.move_page_down();
} else if key.code == KeyCode::Enter {
if let Some(prompt) = self.input.lines().first() {
self.session_tx.send(SessionMessage::SubmitPrompt(prompt.to_string())).await.unwrap();
self.input.clear();
}
} else {
self.input.input_without_shortcuts(key);
}
self.dirty = true;
}
}
Some(event) = self.rx.recv() => {
self.handle_session_event(event);
}
_ = redraw_tick.tick() => {
if self.dirty {
terminal.draw(|frame| self.render(frame))?;
self.dirty = false;
}
}
}
}
}
fn render(&mut self, frame: &mut Frame) {
let [content, status_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area());
let input_height = 1;
let [conversation_area, input_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(input_height)]).areas(content);
frame.render_stateful_widget(ConversationView, conversation_area, &mut self.conversation);
frame.render_widget(&self.input, input_area);
frame.render_widget(self.status, status_area);
}
fn handle_session_event(&mut self, event: UiMessage) {
match event {
UiMessage::NewConversationItem(item) => {
self.conversation.append_item(item);
self.dirty = true;
}
UiMessage::UpdatedConversationItem(item) => {
self.conversation.update_last_item(item);
self.dirty = true;
}
UiMessage::GenerationFinished(usage) => {
self.status.tokens = usage;
}
}
}
}