use crate::{
core::conversation::{Block, ConversationItem, ConversationItemKind},
session::{SessionMessage, TokenUsage},
ui::{
conversation::{ConversationView, ConversationViewModel, HighlightConfig},
statusline::{InputMode, StatusBar},
},
};
use anyhow::Result;
use copypasta::{ClipboardContext, ClipboardProvider};
use crossterm::event::{Event, KeyCode};
use crossterm::event::{EventStream, KeyModifiers};
use futures::StreamExt;
use ratatui::{
Frame, Terminal,
layout::{Constraint, Layout},
prelude::CrosstermBackend,
style::Style,
};
use ratatui_textarea::{CursorMove, TextArea, WrapMode};
use std::io::Stdout;
use tokio::{
sync::mpsc::{Receiver, Sender},
time::{Duration, interval},
};
pub mod conversation;
pub mod statusline;
#[derive(Clone, Debug)]
pub enum ModalAction {
CommandMode(char),
Goto(u32),
InsertMode,
Invalid(String),
MoveDown,
MoveBack,
MoveForward,
MoveUp,
MoveWordBack,
MoveWordEnd,
MoveWordForward,
Paste,
Redo,
Undo,
ViewMode,
YankSelection,
YankSnippet(u32),
}
#[derive(Clone, Debug)]
pub enum UiMessage {
NewConversationItem(ConversationItem),
UpdatedConversationItem(ConversationItem),
GenerationFinished(TokenUsage),
Quit,
}
pub struct Ui {
conversation: ConversationViewModel, input: TextArea<'static>,
status: StatusBar,
session_tx: Sender<SessionMessage>,
rx: Receiver<UiMessage>,
dirty: bool,
quit: bool,
clipboard: ClipboardContext,
action: String,
stashed_input: Vec<String>,
}
impl Ui {
pub fn new(session_tx: Sender<SessionMessage>, rx: Receiver<UiMessage>) -> Self {
let mut input = TextArea::default();
input.set_cursor_line_style(Style::default());
input.set_wrap_mode(WrapMode::Word);
Ui {
conversation: ConversationViewModel::new(HighlightConfig::new()),
input,
status: StatusBar {
input_mode: InputMode::Normal,
model: "Qwen3.6-27B".to_string(),
context_used: 41_287,
context_total: 65_536,
tokens: TokenUsage::default(),
status_message: String::new(),
},
session_tx,
rx,
dirty: true,
quit: false,
clipboard: ClipboardContext::new().unwrap(),
action: String::new(),
stashed_input: Vec::new(),
}
}
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));
while !self.quit {
tokio::select! {
Some(Ok(event)) = input_events.next() => {
self.handle_input_event(event).await;
}
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;
}
}
}
}
Ok(())
}
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 = 3;
let [conversation_area, input_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(input_height)]).areas(content);
let [input_indicator_area, input_field_area] =
Layout::horizontal([Constraint::Length(2), Constraint::Min(10)]).areas(input_area);
frame.render_stateful_widget(ConversationView, conversation_area, &mut self.conversation);
frame.render_widget("❯", input_indicator_area); frame.render_widget(&self.input, input_field_area);
frame.render_widget(self.status.clone(), status_area);
}
async fn handle_input_event(&mut self, event: Event) {
match self.status.input_mode {
InputMode::Normal => self.handle_input_event_normal_mode(event).await,
InputMode::Insert => self.handle_input_event_insert_mode(event).await,
InputMode::View => self.handle_input_event_view_mode(event).await,
InputMode::Command => self.handle_input_event_command_mode(event).await,
}
}
async fn handle_input_event_command_mode(&mut self, event: Event) {
if let Event::Key(key) = event {
match key.code {
KeyCode::Esc => {
self.input.clear();
for line in &self.stashed_input {
self.input.insert_str(line);
self.input.insert_newline();
}
self.stashed_input.clear();
self.status.input_mode = InputMode::Normal;
}
KeyCode::Left => {
self.input.move_cursor(CursorMove::Back);
}
KeyCode::Right => {
self.input.move_cursor(CursorMove::Forward);
}
KeyCode::Enter => {
if let Some(command) = self.input.lines().first() {
self.session_tx
.send(SessionMessage::SubmitCommand(command.to_string()))
.await
.unwrap();
}
self.input.clear();
for line in &self.stashed_input {
self.input.insert_str(line);
self.input.insert_newline();
}
self.stashed_input.clear();
self.status.input_mode = InputMode::Normal;
}
_ => {
self.input.input_without_shortcuts(key);
}
}
self.dirty = true;
}
}
async fn handle_input_event_insert_mode(&mut self, event: Event) {
if let Event::Key(key) = event {
match key.code {
KeyCode::Esc => {
self.status.input_mode = InputMode::Normal;
}
KeyCode::Up => {
self.input.move_cursor(CursorMove::Up);
}
KeyCode::Down => {
self.input.move_cursor(CursorMove::Down);
}
KeyCode::Left => {
self.input.move_cursor(CursorMove::Back);
}
KeyCode::Right => {
self.input.move_cursor(CursorMove::Forward);
}
KeyCode::PageUp => {
self.conversation.move_page_up();
}
KeyCode::PageDown => {
self.conversation.move_page_down();
}
KeyCode::Enter if key.modifiers == KeyModifiers::CONTROL => {
if let Some(prompt) = self.input.lines().first() {
self.session_tx
.send(SessionMessage::SubmitPrompt(prompt.to_string()))
.await
.unwrap();
self.input.clear();
}
self.status.input_mode = InputMode::Normal;
}
_ => {
self.input.input_without_shortcuts(key);
}
}
self.dirty = true;
}
}
async fn handle_input_event_normal_mode(&mut self, event: Event) {
if let Event::Key(key) = event
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
{
match key.code {
KeyCode::Esc => {
}
KeyCode::Up => {
self.conversation.move_line_up();
}
KeyCode::Down => {
self.conversation.move_line_down();
}
KeyCode::PageUp => {
self.conversation.move_page_up();
}
KeyCode::PageDown => {
self.conversation.move_page_down();
}
KeyCode::Enter => {
if let Some(prompt) = self.input.lines().first() {
self.session_tx
.send(SessionMessage::SubmitPrompt(prompt.to_string()))
.await
.unwrap();
self.input.clear();
}
}
KeyCode::Char(c) => {
self.action.push(c);
self.resolve_action();
}
_ => {}
}
self.dirty = true;
}
}
async fn handle_input_event_view_mode(&mut self, event: Event) {
if let Event::Key(key) = event {
match key.code {
KeyCode::Esc => {
self.status.input_mode = InputMode::Normal;
}
KeyCode::Up => {
self.conversation.move_line_up();
}
KeyCode::Down => {
self.conversation.move_line_down();
}
KeyCode::PageUp => {
self.conversation.move_page_up();
}
KeyCode::PageDown => {
self.conversation.move_page_down();
}
KeyCode::Home => { }
KeyCode::End => { }
KeyCode::Tab => { }
_ => {}
}
self.dirty = true;
}
}
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;
}
UiMessage::Quit => {
self.quit = true;
}
}
}
fn resolve_action(&mut self) {
if let Some(modal_action) = self.parse_action() {
match modal_action {
ModalAction::CommandMode(c) => {
self.stashed_input = self.input.lines().to_vec();
self.input.clear();
self.status.input_mode = InputMode::Command;
self.input.insert_str(c.to_string());
}
ModalAction::Goto(_position) => {
}
ModalAction::InsertMode => {
self.status.input_mode = InputMode::Insert;
}
ModalAction::Invalid(_action) => {
}
ModalAction::MoveDown => {
self.input.move_cursor(CursorMove::Down);
}
ModalAction::MoveBack => {
self.input.move_cursor(CursorMove::Forward);
}
ModalAction::MoveForward => {
self.input.move_cursor(CursorMove::Back);
}
ModalAction::MoveUp => {
self.input.move_cursor(CursorMove::Up);
}
ModalAction::MoveWordBack => {
self.input.move_cursor(CursorMove::WordBack);
}
ModalAction::MoveWordEnd => {
self.input.move_cursor(CursorMove::WordEnd);
}
ModalAction::MoveWordForward => {
self.input.move_cursor(CursorMove::WordForward);
}
ModalAction::Paste => {
let content = self.clipboard.get_contents().unwrap_or_default();
self.input.insert_str(content);
}
ModalAction::Redo => {
self.input.redo();
}
ModalAction::Undo => {
self.input.undo();
}
ModalAction::ViewMode => {
self.status.input_mode = InputMode::View;
}
ModalAction::YankSelection => {
}
ModalAction::YankSnippet(position) => {
match self.find_code_snippet(position) {
Some(code_snippet) => {
self.clipboard
.set_contents(code_snippet.to_owned())
.unwrap();
}
None => {
}
}
}
}
self.action.clear();
}
}
fn parse_action(&mut self) -> Option<ModalAction> {
if self.action.is_empty() {
return None;
}
match self.action.as_str() {
"b" => Some(ModalAction::MoveWordBack),
"e" => Some(ModalAction::MoveWordEnd),
"i" => Some(ModalAction::InsertMode),
"h" => Some(ModalAction::MoveBack),
"j" => Some(ModalAction::MoveDown),
"k" => Some(ModalAction::MoveUp),
"l" => Some(ModalAction::MoveForward),
"p" => Some(ModalAction::Paste),
"u" => Some(ModalAction::Undo),
"U" => Some(ModalAction::Redo),
"v" => Some(ModalAction::ViewMode),
"w" => Some(ModalAction::MoveWordForward),
"y" => Some(ModalAction::YankSelection),
":" => Some(ModalAction::CommandMode(':')),
_ => {
let chars: Vec<char> = self.action.chars().collect();
if chars.len() < 2 {
return None;
}
let last_char = chars.last()?;
let num_part: String = chars[..chars.len() - 1].iter().collect();
if let Ok(position) = num_part.parse::<u32>()
&& !last_char.is_numeric()
{
match last_char {
'y' => Some(ModalAction::YankSnippet(position)),
'g' => Some(ModalAction::Goto(position)),
_ => Some(ModalAction::Invalid(self.action.clone())),
}
} else {
Some(ModalAction::Invalid(self.action.clone()))
}
}
}
}
fn find_code_snippet(&self, copy_id: u32) -> Option<String> {
let mut snippet = None;
let mut backlog = Vec::new();
for item in &self.conversation.conversation_items {
match &item.content {
ConversationItemKind::UserMessage(message_content) => {
backlog.extend(message_content.content().blocks.clone());
}
ConversationItemKind::AssistantMessage(message_content) => {
backlog.extend(message_content.content().blocks.clone());
}
ConversationItemKind::AssistantReasoning(message_content) => {
backlog.extend(message_content.content().blocks.clone());
}
ConversationItemKind::ToolInvocation(_) => {}
ConversationItemKind::ToolOutput(_) => {}
ConversationItemKind::Notification(_, _) => {}
}
}
while let Some(current_block) = backlog.pop()
&& snippet.is_none()
{
match current_block {
Block::Code {
lang: _lang,
content,
copy_id: candidate_copy_id,
} if copy_id == candidate_copy_id => snippet = Some(content),
Block::List { content } => {
for item in content {
backlog.extend(item.content.clone());
}
}
Block::Quote(blocks) => {
backlog.extend(blocks.clone());
}
_ => {}
}
}
snippet
}
}