Initial commit: Meow TUI AI chat client

A Rust terminal chat client with OpenAI-compatible provider support,
real-time SSE streaming, markdown rendering with syntax highlighting,
and persistent chat history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:18:07 +08:00
commit a5d6041764
14 changed files with 4524 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
/.idea
*.iml
+143
View File
@@ -0,0 +1,143 @@
# Meow - Rust Terminal AI Chat Client
A high-performance terminal UI (TUI) AI chat client written in Rust, supporting custom OpenAI-compatible providers (DeepSeek, Kimi, etc.).
## Overview
Meow is a pure-text terminal chat application. It features:
- OpenAI-compatible provider abstraction layer
- Real-time streaming (SSE) chat output
- Markdown rendering with syntax highlighting
- Persistent chat history in JSON Lines format
- Mouse scroll support for message history
## Tech Stack
| Component | Crate |
|-----------|-------|
| Async Runtime | `tokio` |
| HTTP Client | `reqwest` (with streaming/SSE) |
| TUI Framework | `ratatui` + `crossterm` |
| Markdown Parser | `pulldown-cmark` |
| Syntax Highlighting | `syntect` (fancy-regex backend) |
| Serialization | `serde` + `serde_json` + `toml` |
| Configuration Path | `dirs` (cross-platform home dir) |
## Architecture
### Layered Design
```
TUI Layer (ratatui widgets)
├── chat.rs (implicit in app.rs)
├── model_select.rs (popup)
├── provider_config.rs (form)
├── input.rs (multi-line text area)
└── markdown.rs (rendering pipeline)
App Layer (state machine)
└── app.rs - coordinates all modules, event loop, async stream handling
Provider Layer
├── providers/mod.rs - Provider trait
└── providers/openai.rs - OpenAI-compatible implementation
Storage Layer
├── config.rs - ~/.meow/config.toml
├── storage.rs - ~/.meow/data/sessions/*.jsonl
└── message.rs - core data types
```
### Data Flow
1. User types message in input box
2. `Enter` triggers `App::send_message()`
3. User message is saved to disk via `Storage::append_message()`
4. A tokio task spawns to call `Provider::chat_stream()`
5. SSE chunks flow back through `tokio::sync::mpsc::UnboundedChannel`
6. Main event loop polls channel via `App::poll_stream()` and redraws
7. When stream ends (`__STREAM_END__`), the full assistant message is persisted
## Project Structure
```
src/
├── main.rs # Entry point: initializes tokio, Tui, App, event loop
├── app.rs # App state machine: Chat / ModelSelect / ProviderConfig
├── config.rs # Config & ProviderConfig structs, load/save to ~/.meow/config.toml
├── message.rs # Role enum, Message, ChatSession
├── storage.rs # JSON Lines persistence for chat sessions
├── providers/
│ ├── mod.rs # Provider trait, Model struct, ProviderError
│ └── openai.rs # OpenAICompatibleProvider: /v1/models, /v1/chat/completions SSE
└── tui/
├── mod.rs # Tui struct: terminal init/restore, raw mode, panic hooks
├── input.rs # InputState: multi-line editor with cursor movement
└── markdown.rs # MarkdownRenderer: pulldown-cmark -> ratatui Lines + syntect highlighting
```
## Configuration
Config is stored at `~/.meow/config.toml` (cross-platform via `dirs::home_dir()`).
Example:
```toml
[[providers]]
name = "DeepSeek"
base_url = "https://api.deepseek.com"
api_key = "sk-..."
default_model = "deepseek-chat"
```
Chat sessions are stored as JSON Lines at `~/.meow/data/sessions/<uuid>.jsonl`.
## Keybindings
| Key | Action |
|-----|--------|
| `Enter` | Send message |
| `Shift+Enter` | Newline in input |
| `Ctrl+P` | Open model selector |
| `Ctrl+S` | Open provider config |
| `Ctrl+N` | Start new chat session |
| `Ctrl+B` | Toggle sidebar |
| `Ctrl+R` | Reset current chat |
| `Ctrl+C` | Stop streaming (while AI is typing) |
| `Ctrl+Q` | Quit |
| `Mouse Scroll` | Scroll message history |
## Provider Protocol
The `Provider` trait (`async_trait`) defines:
- `name() -> &str`
- `config() -> &ProviderConfig`
- `list_models() -> Result<Vec<Model>>`
- `chat_stream(model, messages) -> Result<BoxStream<Result<String>>>`
`OpenAICompatibleProvider` implements this for any OpenAI API-compatible endpoint.
## Markdown Support
Rendered elements:
- **Bold** / *Italic*
- `Inline code` (dark background)
- Code blocks with syntax highlighting (syntect themes)
- Bullet / numbered lists
- Blockquotes (│ prefix)
- Horizontal rules
## First-Time Setup
On first run, if no providers exist, the app automatically enters the **Add Provider** form. Fill in name, base URL, and API key to get started.
## Build
```bash
cargo build --release
```
The binary will be at `target/release/meow.exe` (Windows) or `target/release/meow` (Unix).
Generated
+2532
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "meow"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
dirs = "6"
anyhow = "1"
thiserror = "1"
async-trait = "0.1"
futures = "0.3"
ratatui = "0.29"
crossterm = "0.28"
pulldown-cmark = "0.12"
syntect = { version = "5", default-features = false, features = ["default-themes", "default-syntaxes", "regex-fancy"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
+811
View File
@@ -0,0 +1,811 @@
use crate::{
config::{Config, ProviderConfig},
message::{ChatSession, Message, Role},
providers::{openai::OpenAICompatibleProvider, Model, Provider},
storage::Storage,
tui::{input::InputState, markdown::MarkdownRenderer},
};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{
Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
Frame,
};
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub struct ProviderForm {
pub name: String,
pub base_url: String,
pub api_key: String,
pub default_model: String,
pub focus: usize,
}
impl Default for ProviderForm {
fn default() -> Self {
Self {
name: String::new(),
base_url: String::new(),
api_key: String::new(),
default_model: String::new(),
focus: 0,
}
}
}
#[derive(Debug, Clone)]
pub enum AppState {
Chat,
ModelSelect {
models: Vec<Model>,
selected: usize,
filtered: Vec<usize>,
filter_input: String,
},
ProviderConfig {
form: ProviderForm,
editing: Option<usize>,
},
}
pub struct App {
pub state: AppState,
pub config: Config,
pub current_provider_idx: Option<usize>,
pub current_model: Option<String>,
pub current_session: ChatSession,
pub input: InputState,
pub messages_scroll: usize,
pub is_streaming: bool,
pub stream_buffer: String,
stream_tx: mpsc::UnboundedSender<String>,
stream_rx: mpsc::UnboundedReceiver<String>,
pub providers: Vec<Box<dyn Provider>>,
pub markdown_renderer: MarkdownRenderer,
pub quit: bool,
pub show_sidebar: bool,
pub status_message: Option<String>,
}
impl App {
pub async fn new() -> Result<Self> {
let config = Config::load()?;
let providers: Vec<Box<dyn Provider>> = config
.providers
.iter()
.map(|p| {
let p = p.clone();
Box::new(OpenAICompatibleProvider::new(p)) as Box<dyn Provider>
})
.collect();
let (stream_tx, stream_rx) = mpsc::unbounded_channel();
let current_provider_idx = if providers.is_empty() { None } else { Some(0) };
let current_model = current_provider_idx
.and_then(|i| config.providers.get(i))
.and_then(|p| p.default_model.clone());
let state = if config.is_empty() {
AppState::ProviderConfig {
form: ProviderForm::default(),
editing: None,
}
} else {
AppState::Chat
};
let current_session = ChatSession::new("New Chat");
Ok(Self {
state,
config,
current_provider_idx,
current_model,
current_session,
input: InputState::new(),
messages_scroll: 0,
is_streaming: false,
stream_buffer: String::new(),
stream_tx,
stream_rx,
providers,
markdown_renderer: MarkdownRenderer::new(),
quit: false,
show_sidebar: true,
status_message: None,
})
}
pub fn rebuild_providers(&mut self) {
self.providers = self
.config
.providers
.iter()
.map(|p| {
let p = p.clone();
Box::new(OpenAICompatibleProvider::new(p)) as Box<dyn Provider>
})
.collect();
}
pub fn current_provider(&self) -> Option<&dyn Provider> {
self.current_provider_idx
.and_then(|i| self.providers.get(i))
.map(|p| p.as_ref())
}
pub async fn on_key_event(&mut self, key: KeyEvent) -> Result<()> {
if self.is_streaming && key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
self.is_streaming = false;
self.finish_stream_message();
return Ok(());
}
match &mut self.state {
AppState::Chat => self.handle_chat_key(key).await,
AppState::ModelSelect { .. } => self.handle_model_select_key(key).await,
AppState::ProviderConfig { .. } => self.handle_provider_config_key(key).await,
}
}
async fn handle_chat_key(&mut self, key: KeyEvent) -> Result<()> {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
self.quit = true;
}
(KeyCode::Char('p'), KeyModifiers::CONTROL) => {
if let Some(idx) = self.current_provider_idx {
self.status_message = Some("Loading models...".to_string());
if let Some(provider) = self.providers.get(idx) {
match provider.list_models().await {
Ok(models) => {
let filtered: Vec<usize> = (0..models.len()).collect();
self.state = AppState::ModelSelect {
models,
selected: 0,
filtered,
filter_input: String::new(),
};
}
Err(e) => {
self.status_message = Some(format!("Failed to load models: {}", e));
}
}
}
}
}
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
self.state = AppState::ProviderConfig {
form: ProviderForm::default(),
editing: None,
};
}
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
self.save_current_session()?;
self.current_session = ChatSession::new("New Chat");
self.messages_scroll = 0;
}
(KeyCode::Char('b'), KeyModifiers::CONTROL) => {
self.show_sidebar = !self.show_sidebar;
}
(KeyCode::Char('r'), KeyModifiers::CONTROL) => {
if !self.is_streaming {
self.input.clear();
self.current_session = ChatSession::new("New Chat");
self.messages_scroll = 0;
}
}
_ => {
if self.is_streaming {
return Ok(());
}
let action = self.input.handle_key_event(key);
match action {
crate::tui::input::InputAction::Submit => {
self.send_message().await?;
}
crate::tui::input::InputAction::CursorMoved
| crate::tui::input::InputAction::Typed => {}
_ => {}
}
}
}
Ok(())
}
async fn handle_model_select_key(&mut self, key: KeyEvent) -> Result<()> {
if let AppState::ModelSelect {
models,
selected,
filtered,
filter_input,
} = &mut self.state
{
match key.code {
KeyCode::Esc => {
self.state = AppState::Chat;
}
KeyCode::Up => {
if *selected > 0 {
*selected -= 1;
}
}
KeyCode::Down => {
if *selected + 1 < filtered.len() {
*selected += 1;
}
}
KeyCode::Enter => {
if let Some(&idx) = filtered.get(*selected) {
if let Some(model) = models.get(idx) {
self.current_model = Some(model.id.clone());
if let Some(pidx) = self.current_provider_idx {
if let Some(p) = self.config.providers.get_mut(pidx) {
p.default_model = Some(model.id.clone());
let _ = self.config.save();
}
}
}
}
self.state = AppState::Chat;
}
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE => {
filter_input.push(c);
*selected = 0;
*filtered = models
.iter()
.enumerate()
.filter(|(_, m)| {
m.id.to_lowercase().contains(&filter_input.to_lowercase())
|| m.name.to_lowercase().contains(&filter_input.to_lowercase())
})
.map(|(i, _)| i)
.collect();
}
KeyCode::Backspace => {
filter_input.pop();
*selected = 0;
*filtered = models
.iter()
.enumerate()
.filter(|(_, m)| {
m.id.to_lowercase().contains(&filter_input.to_lowercase())
|| m.name.to_lowercase().contains(&filter_input.to_lowercase())
})
.map(|(i, _)| i)
.collect();
}
_ => {}
}
}
Ok(())
}
async fn handle_provider_config_key(&mut self, key: KeyEvent) -> Result<()> {
if let AppState::ProviderConfig { form, editing } = &mut self.state {
let mut fields: Vec<&mut String> = vec![
&mut form.name,
&mut form.base_url,
&mut form.api_key,
&mut form.default_model,
];
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
if self.config.is_empty() {
self.quit = true;
} else {
self.state = AppState::Chat;
}
}
(KeyCode::Tab, _) | (KeyCode::Down, KeyModifiers::CONTROL) => {
form.focus = (form.focus + 1) % fields.len();
}
(KeyCode::BackTab, _) => {
form.focus = (form.focus + fields.len() - 1) % fields.len();
}
(KeyCode::Enter, _) => {
if !form.name.is_empty() && !form.base_url.is_empty() && !form.api_key.is_empty() {
let new_config = ProviderConfig {
name: form.name.clone(),
base_url: form.base_url.clone(),
api_key: form.api_key.clone(),
default_model: if form.default_model.is_empty() {
None
} else {
Some(form.default_model.clone())
},
};
if let Some(idx) = editing {
if *idx < self.config.providers.len() {
self.config.providers[*idx] = new_config;
}
} else {
self.config.providers.push(new_config);
}
self.config.save()?;
self.rebuild_providers();
if self.current_provider_idx.is_none() && !self.providers.is_empty() {
self.current_provider_idx = Some(0);
self.current_model = self.config.providers[0].default_model.clone();
}
self.state = AppState::Chat;
}
}
(KeyCode::Char(c), KeyModifiers::NONE)
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
if let Some(field) = fields.get_mut(form.focus) {
field.push(c);
}
}
(KeyCode::Backspace, _) => {
if let Some(field) = fields.get_mut(form.focus) {
field.pop();
}
}
_ => {}
}
}
Ok(())
}
async fn send_message(&mut self) -> Result<()> {
let text = self.input.text();
if text.trim().is_empty() {
return Ok(());
}
self.input.clear();
let provider_config = match self.current_provider_idx {
Some(idx) => match self.providers.get(idx) {
Some(p) => p.config().clone(),
None => {
self.status_message = Some("No provider configured".to_string());
return Ok(());
}
},
None => {
self.status_message = Some("No provider configured".to_string());
return Ok(());
}
};
let model = match &self.current_model {
Some(m) => m.clone(),
None => {
self.status_message = Some("No model selected".to_string());
return Ok(());
}
};
let user_msg = Message::user(&text);
Storage::append_message(self.current_session.id, &user_msg)?;
self.current_session.add_message(user_msg);
let messages: Vec<Message> = self.current_session.messages.clone();
let tx = self.stream_tx.clone();
let model_clone = model.clone();
self.is_streaming = true;
self.stream_buffer.clear();
tokio::spawn(async move {
let p = OpenAICompatibleProvider::new(provider_config);
match p.chat_stream(&model_clone, &messages).await {
Ok(mut stream) => {
while let Some(result) = stream.next().await {
match result {
Ok(chunk) => {
if tx.send(chunk).is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(format!("\n[Error: {}]", e));
break;
}
}
}
}
Err(e) => {
let _ = tx.send(format!("\n[Error: {}]", e));
}
}
let _ = tx.send("__STREAM_END__".to_string());
});
Ok(())
}
pub fn poll_stream(&mut self) -> bool {
let mut updated = false;
while let Ok(chunk) = self.stream_rx.try_recv() {
if chunk == "__STREAM_END__" {
self.finish_stream_message();
updated = true;
break;
}
self.stream_buffer.push_str(&chunk);
updated = true;
}
updated
}
fn finish_stream_message(&mut self) {
self.is_streaming = false;
if !self.stream_buffer.is_empty() {
let msg = Message::assistant(&self.stream_buffer);
let _ = Storage::append_message(self.current_session.id, &msg);
self.current_session.add_message(msg);
self.stream_buffer.clear();
}
}
fn save_current_session(&self) -> Result<()> {
for msg in &self.current_session.messages {
Storage::append_message(self.current_session.id, msg)?;
}
Ok(())
}
pub fn draw(&self, frame: &mut Frame) {
let area = frame.area();
match &self.state {
AppState::Chat => self.draw_chat(frame, area),
AppState::ModelSelect { .. } => {
self.draw_chat(frame, area);
self.draw_model_select(frame, area);
}
AppState::ProviderConfig { .. } => {
self.draw_chat(frame, area);
self.draw_provider_config(frame, area);
}
}
}
fn draw_chat(&self, frame: &mut Frame, area: Rect) {
let main_chunks = if self.show_sidebar {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(25), Constraint::Min(0)])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0)])
.split(area)
};
let chat_area = if self.show_sidebar {
main_chunks[1]
} else {
main_chunks[0]
};
if self.show_sidebar {
self.draw_sidebar(frame, main_chunks[0]);
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(6),
])
.split(chat_area);
self.draw_header(frame, chunks[0]);
self.draw_messages(frame, chunks[1]);
self.draw_status_bar(frame, chunks[2]);
self.draw_input(frame, chunks[3]);
}
fn draw_sidebar(&self, frame: &mut Frame, area: Rect) {
let provider_name = self
.current_provider()
.map(|p| p.name())
.unwrap_or("No Provider");
let model_name = self.current_model.as_deref().unwrap_or("No Model");
let items = vec![
Line::from(Span::styled("Provider", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(provider_name, Style::default().fg(Color::Cyan))),
Line::raw(""),
Line::from(Span::styled("Model", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(
model_name,
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
Line::raw(""),
Line::from(Span::styled("Session", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(
format!("{} msgs", self.current_session.messages.len()),
Style::default().fg(Color::Yellow),
)),
];
let block = Block::default()
.title(" Info ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let paragraph = Paragraph::new(Text::from(items)).block(block);
frame.render_widget(paragraph, area);
}
fn draw_header(&self, frame: &mut Frame, area: Rect) {
let title = if self.is_streaming {
" Meow AI Chat ● Streaming... "
} else {
" Meow AI Chat "
};
let header = Paragraph::new("")
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.title_alignment(Alignment::Center),
)
.alignment(Alignment::Center);
frame.render_widget(header, area);
}
fn draw_messages(&self, frame: &mut Frame, area: Rect) {
let mut all_lines: Vec<Line> = Vec::new();
for msg in &self.current_session.messages {
let (label_color, align_right) = match msg.role {
Role::User => (Color::Green, true),
Role::Assistant => (Color::Blue, false),
Role::System => (Color::Yellow, false),
};
let label = format!("[{}]", msg.role);
if align_right {
all_lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(label, Style::default().fg(label_color).add_modifier(Modifier::BOLD)),
]));
} else {
all_lines.push(Line::from(Span::styled(
label,
Style::default().fg(label_color).add_modifier(Modifier::BOLD),
)));
}
let rendered = self.markdown_renderer.render(&msg.content);
for line in rendered {
if align_right {
let mut spans = line.spans.clone();
spans.insert(0, Span::styled(" ", Style::default()));
all_lines.push(Line::from(spans));
} else {
all_lines.push(line);
}
}
all_lines.push(Line::raw(""));
}
if self.is_streaming && !self.stream_buffer.is_empty() {
all_lines.push(Line::from(Span::styled(
"[Assistant]",
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
)));
let rendered = self.markdown_renderer.render(&self.stream_buffer);
for line in rendered {
all_lines.push(line);
}
}
let total_lines = all_lines.len();
let visible_height = area.height as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll = self.messages_scroll.min(max_scroll);
let paragraph = Paragraph::new(Text::from(all_lines))
.wrap(Wrap { trim: false })
.scroll((scroll as u16, 0))
.block(Block::default().borders(Borders::ALL).title(" Messages "));
frame.render_widget(paragraph, area);
if max_scroll > 0 {
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll);
let scrollbar_area = area.inner(Margin {
horizontal: 0,
vertical: 1,
});
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some("")),
scrollbar_area,
&mut scrollbar_state,
);
}
}
fn draw_status_bar(&self, frame: &mut Frame, area: Rect) {
let status = self.status_message.as_deref().unwrap_or_else(|| {
if self.is_streaming {
"Press Ctrl+C to stop streaming"
} else {
"Ctrl+P: Models | Ctrl+S: Config | Ctrl+N: New Chat | Ctrl+B: Sidebar | Ctrl+Q: Quit"
}
});
let status_bar = Paragraph::new(Span::styled(status, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Center);
frame.render_widget(status_bar, area);
}
fn draw_input(&self, frame: &mut Frame, area: Rect) {
let cursor = self.input.cursor_position();
let lines = self.input.lines();
let input_text: Vec<Line> = if lines.is_empty() {
vec![Line::from(Span::styled(
"Type a message... (Shift+Enter for newline, Enter to send)",
Style::default().fg(Color::DarkGray),
))]
} else {
lines.iter().map(|l| Line::raw(l.as_str())).collect()
};
let block = Block::default()
.borders(Borders::ALL)
.title(" Input ")
.border_style(if self.is_streaming {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
});
let paragraph = Paragraph::new(Text::from(input_text)).block(block);
frame.render_widget(paragraph, area);
if !self.is_streaming {
let x = area.x + 1 + cursor.0 as u16;
let y = area.y + 1 + cursor.1 as u16;
frame.set_cursor_position((x, y));
}
}
fn draw_model_select(&self, frame: &mut Frame, area: Rect) {
if let AppState::ModelSelect {
models,
selected,
filtered,
filter_input,
} = &self.state
{
let popup_area = Self::centered_rect(60, 70, area);
frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(i, &idx)| {
let model = &models[idx];
let style = if i == *selected {
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(model.id.clone()).style(style)
})
.collect();
let filter_text = if filter_input.is_empty() {
"Type to filter...".to_string()
} else {
filter_input.clone()
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Select Model | {} ", filter_text)),
)
.highlight_symbol("> ");
frame.render_widget(list, popup_area);
}
}
fn draw_provider_config(&self, frame: &mut Frame, area: Rect) {
if let AppState::ProviderConfig { form, editing } = &self.state {
let popup_area = Self::centered_rect(70, 60, area);
frame.render_widget(Clear, popup_area);
let title = if editing.is_some() {
" Edit Provider "
} else {
" Add Provider (First Setup) "
};
let fields = vec![
("Name", &form.name, 0),
("Base URL", &form.base_url, 1),
("API Key", &form.api_key, 2),
("Default Model (optional)", &form.default_model, 3),
];
let mut lines: Vec<Line> = Vec::new();
for (label, value, idx) in fields {
let is_focused = form.focus == idx;
let label_style = if is_focused {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let value_style = if is_focused {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(format!("{}: ", label), label_style),
Span::styled(value.clone(), value_style),
]));
lines.push(Line::raw(""));
}
lines.push(Line::from(Span::styled(
"Tab: Next field | Enter: Save | Esc: Cancel",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow));
let paragraph = Paragraph::new(Text::from(lines)).block(block);
frame.render_widget(paragraph, popup_area);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn scroll_messages(&mut self, delta: i32) {
let _total = self.current_session.messages.len()
+ if self.is_streaming { 5 } else { 0 };
if delta < 0 {
self.messages_scroll = self.messages_scroll.saturating_sub((-delta) as usize);
} else {
self.messages_scroll = self.messages_scroll.saturating_add(delta as usize);
}
}
}
use futures::StreamExt;
+65
View File
@@ -0,0 +1,65 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
pub name: String,
pub base_url: String,
pub api_key: String,
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub providers: Vec<ProviderConfig>,
}
impl Config {
pub fn meow_dir() -> PathBuf {
dirs::home_dir()
.expect("Failed to get home directory")
.join(".meow")
}
pub fn config_path() -> PathBuf {
Self::meow_dir().join("config.toml")
}
pub fn data_dir() -> PathBuf {
Self::meow_dir().join("data")
}
pub fn sessions_dir() -> PathBuf {
Self::data_dir().join("sessions")
}
pub fn load() -> Result<Self> {
let path = Self::config_path();
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {}", path.display()))?;
let config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config from {}", path.display()))?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let content = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write config to {}", path.display()))?;
Ok(())
}
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
}
+65
View File
@@ -0,0 +1,65 @@
mod app;
mod config;
mod message;
mod providers;
mod storage;
mod tui;
use anyhow::Result;
use app::App;
use crossterm::event::{self, Event, MouseEventKind};
use std::time::Duration;
use tui::Tui;
#[tokio::main]
async fn main() -> Result<()> {
let mut tui = Tui::new()?;
tui.enter()?;
let mut app = App::new().await?;
let mut last_tick = std::time::Instant::now();
let tick_rate = Duration::from_millis(100);
loop {
tui.draw(|frame| app.draw(frame))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => {
if key.kind == event::KeyEventKind::Press {
app.on_key_event(key).await?;
}
}
Event::Mouse(mouse) => {
if let MouseEventKind::ScrollUp = mouse.kind {
app.scroll_messages(-3);
} else if let MouseEventKind::ScrollDown = mouse.kind {
app.scroll_messages(3);
}
}
Event::Resize(_, _) => {}
_ => {}
}
}
if app.poll_stream() {
// Stream updated, redraw will happen on next loop iteration
}
if app.quit {
break;
}
if last_tick.elapsed() >= tick_rate {
last_tick = std::time::Instant::now();
}
}
tui.exit()?;
Ok(())
}
+74
View File
@@ -0,0 +1,74 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
System,
User,
Assistant,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::System => write!(f, "System"),
Role::User => write!(f, "User"),
Role::Assistant => write!(f, "Assistant"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: Uuid,
pub role: Role,
pub content: String,
pub created_at: DateTime<Utc>,
}
impl Message {
pub fn new(role: Role, content: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
role,
content: content.into(),
created_at: Utc::now(),
}
}
pub fn system(content: impl Into<String>) -> Self {
Self::new(Role::System, content)
}
pub fn user(content: impl Into<String>) -> Self {
Self::new(Role::User, content)
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::new(Role::Assistant, content)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatSession {
pub id: Uuid,
pub title: String,
pub created_at: DateTime<Utc>,
pub messages: Vec<Message>,
}
impl ChatSession {
pub fn new(title: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
title: title.into(),
created_at: Utc::now(),
messages: Vec::new(),
}
}
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
}
}
+39
View File
@@ -0,0 +1,39 @@
pub mod openai;
use crate::{config::ProviderConfig, message::Message};
use async_trait::async_trait;
use futures::stream::BoxStream;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Model {
pub id: String,
pub name: String,
}
#[derive(Error, Debug)]
pub enum ProviderError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error: {message}")]
Api { message: String },
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("Stream parse error: {0}")]
StreamParse(String),
}
#[async_trait]
pub trait Provider: Send + Sync {
fn name(&self) -> &str;
fn config(&self) -> &ProviderConfig;
async fn list_models(&self) -> Result<Vec<Model>, ProviderError>;
async fn chat_stream(
&self,
model: &str,
messages: &[Message],
) -> Result<BoxStream<'_, Result<String, ProviderError>>, ProviderError>;
}
+186
View File
@@ -0,0 +1,186 @@
use crate::{
config::ProviderConfig,
message::{Message, Role},
providers::{Model, Provider, ProviderError},
};
use async_trait::async_trait;
use futures::{stream::BoxStream, StreamExt};
use serde::{Deserialize, Serialize};
pub struct OpenAICompatibleProvider {
config: ProviderConfig,
client: reqwest::Client,
}
impl OpenAICompatibleProvider {
pub fn new(config: ProviderConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
fn api_url(&self, path: &str) -> String {
let base = self.config.base_url.trim_end_matches('/');
format!("{}{}", base, path)
}
}
#[async_trait]
impl Provider for OpenAICompatibleProvider {
fn name(&self) -> &str {
&self.config.name
}
fn config(&self) -> &ProviderConfig {
&self.config
}
async fn list_models(&self) -> Result<Vec<Model>, ProviderError> {
let response = self
.client
.get(self.api_url("/v1/models"))
.header("Authorization", format!("Bearer {}", self.config.api_key))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(ProviderError::Api { message: text });
}
let data: ModelsResponse = response.json().await?;
Ok(data
.data
.into_iter()
.map(|m| Model {
id: m.id.clone(),
name: m.id,
})
.collect())
}
async fn chat_stream(
&self,
model: &str,
messages: &[Message],
) -> Result<BoxStream<'_, Result<String, ProviderError>>, ProviderError> {
let body = ChatCompletionRequest {
model: model.to_string(),
messages: messages.iter().map(|m| ChatMessage::from(m.clone())).collect(),
stream: true,
};
let response = self
.client
.post(self.api_url("/v1/chat/completions"))
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(ProviderError::Api { message: text });
}
let stream = response.bytes_stream().map(move |result| {
result.map_err(ProviderError::Http)
});
let parsed = stream
.flat_map(|result| {
futures::stream::iter(match result {
Ok(bytes) => parse_sse_chunk(&bytes),
Err(e) => vec![Err(e)],
})
})
.boxed();
Ok(parsed)
}
}
fn parse_sse_chunk(bytes: &[u8]) -> Vec<Result<String, ProviderError>> {
let text = String::from_utf8_lossy(bytes);
let mut results = Vec::new();
for line in text.lines() {
let line = line.trim();
if !line.starts_with("data: ") {
continue;
}
let data = &line[6..];
if data == "[DONE]" {
break;
}
match serde_json::from_str::<ChatCompletionChunk>(data) {
Ok(chunk) => {
if let Some(choice) = chunk.choices.first() {
if let Some(content) = &choice.delta.content {
if !content.is_empty() {
results.push(Ok(content.clone()));
}
}
}
}
Err(e) => {
results.push(Err(ProviderError::StreamParse(e.to_string())));
}
}
}
results
}
#[derive(Debug, Deserialize)]
struct ModelsResponse {
data: Vec<ModelData>,
}
#[derive(Debug, Deserialize)]
struct ModelData {
id: String,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<ChatMessage>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct ChatMessage {
role: String,
content: String,
}
impl From<Message> for ChatMessage {
fn from(msg: Message) -> Self {
Self {
role: match msg.role {
Role::System => "system".to_string(),
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
},
content: msg.content,
}
}
}
#[derive(Debug, Deserialize)]
struct ChatCompletionChunk {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
delta: Delta,
}
#[derive(Debug, Deserialize)]
struct Delta {
content: Option<String>,
}
+82
View File
@@ -0,0 +1,82 @@
use crate::{config::Config, message::Message};
use anyhow::{Context, Result};
use std::fs::OpenOptions;
use std::io::{BufRead, Write};
use uuid::Uuid;
pub struct Storage;
impl Storage {
pub fn session_path(session_id: Uuid) -> std::path::PathBuf {
Config::sessions_dir().join(format!("{}.jsonl", session_id))
}
pub fn load_session(session_id: Uuid) -> Result<Vec<Message>> {
let path = Self::session_path(session_id);
if !path.exists() {
return Ok(Vec::new());
}
let file = std::fs::File::open(&path)
.with_context(|| format!("Failed to open session file {}", path.display()))?;
let reader = std::io::BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let message: Message = serde_json::from_str(&line)
.with_context(|| format!("Failed to parse message in session {}", session_id))?;
messages.push(message);
}
Ok(messages)
}
pub fn append_message(session_id: Uuid, message: &Message) -> Result<()> {
let path = Self::session_path(session_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("Failed to open session file {}", path.display()))?;
let json = serde_json::to_string(message)?;
writeln!(file, "{}", json)?;
file.flush()?;
Ok(())
}
pub fn list_sessions() -> Result<Vec<(Uuid, usize)>> {
let dir = Config::sessions_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(&dir)
.with_context(|| format!("Failed to read sessions dir {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if let Some(ext) = path.extension() {
if ext != "jsonl" {
continue;
}
}
if let Some(stem) = path.file_stem() {
if let Ok(id) = Uuid::parse_str(&stem.to_string_lossy()) {
let count = Self::count_messages(id)?;
sessions.push((id, count));
}
}
}
Ok(sessions)
}
fn count_messages(session_id: Uuid) -> Result<usize> {
let messages = Self::load_session(session_id)?;
Ok(messages.len())
}
}
+218
View File
@@ -0,0 +1,218 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone)]
pub struct InputState {
lines: Vec<String>,
cursor_row: usize,
cursor_col: usize,
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
impl InputState {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
cursor_row: 0,
cursor_col: 0,
}
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
pub fn clear(&mut self) {
self.lines = vec![String::new()];
self.cursor_row = 0;
self.cursor_col = 0;
}
pub fn handle_key_event(&mut self, key: KeyEvent) -> InputAction {
match (key.code, key.modifiers) {
(KeyCode::Char(c), KeyModifiers::NONE)
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
self.insert_char(c);
InputAction::Typed
}
(KeyCode::Enter, KeyModifiers::NONE) => {
InputAction::Submit
}
(KeyCode::Enter, KeyModifiers::SHIFT) => {
self.insert_newline();
InputAction::Typed
}
(KeyCode::Backspace, _) => {
self.backspace();
InputAction::Typed
}
(KeyCode::Delete, _) => {
self.delete();
InputAction::Typed
}
(KeyCode::Left, _) => {
self.move_left();
InputAction::CursorMoved
}
(KeyCode::Right, _) => {
self.move_right();
InputAction::CursorMoved
}
(KeyCode::Up, _) => {
self.move_up();
InputAction::CursorMoved
}
(KeyCode::Down, _) => {
self.move_down();
InputAction::CursorMoved
}
(KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.cursor_col = 0;
InputAction::CursorMoved
}
(KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.cursor_col = char_count(&self.lines[self.cursor_row]);
InputAction::CursorMoved
}
_ => InputAction::None,
}
}
fn insert_char(&mut self, c: char) {
let line = &mut self.lines[self.cursor_row];
let byte_idx = byte_index(line, self.cursor_col);
line.insert(byte_idx, c);
self.cursor_col += 1;
}
fn insert_newline(&mut self) {
let line = &mut self.lines[self.cursor_row];
let byte_idx = byte_index(line, self.cursor_col);
let rest: String = line.split_off(byte_idx);
self.cursor_row += 1;
self.cursor_col = 0;
self.lines.insert(self.cursor_row, rest);
}
fn backspace(&mut self) {
if self.cursor_col > 0 {
let line = &mut self.lines[self.cursor_row];
let start = byte_index(line, self.cursor_col - 1);
let end = byte_index(line, self.cursor_col);
line.drain(start..end);
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
let removed = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
self.cursor_col = char_count(&self.lines[self.cursor_row]);
self.lines[self.cursor_row].push_str(&removed);
}
}
fn delete(&mut self) {
let line = &self.lines[self.cursor_row];
if self.cursor_col < char_count(line) {
let line = &mut self.lines[self.cursor_row];
let start = byte_index(line, self.cursor_col);
let end = byte_index(line, self.cursor_col + 1);
line.drain(start..end);
} else if self.cursor_row + 1 < self.lines.len() {
let next = self.lines.remove(self.cursor_row + 1);
self.lines[self.cursor_row].push_str(&next);
}
}
fn move_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = char_count(&self.lines[self.cursor_row]);
}
}
fn move_right(&mut self) {
if self.cursor_col < char_count(&self.lines[self.cursor_row]) {
self.cursor_col += 1;
} else if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.cursor_col = 0;
}
}
fn move_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.cursor_col.min(char_count(&self.lines[self.cursor_row]));
}
}
fn move_down(&mut self) {
if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.cursor_col = self.cursor_col.min(char_count(&self.lines[self.cursor_row]));
}
}
pub fn cursor_position(&self) -> (usize, usize) {
let line = &self.lines[self.cursor_row];
let byte_idx = byte_index(line, self.cursor_col);
let display_col = display_width(&line[..byte_idx]);
(display_col, self.cursor_row)
}
pub fn lines(&self) -> &[String] {
&self.lines
}
}
fn char_count(s: &str) -> usize {
s.chars().count()
}
fn byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(s.len())
}
fn display_width(s: &str) -> usize {
s.chars()
.map(|c| {
let cp = c as u32;
// CJK characters and other full-width characters
if (0x1100..=0x115F).contains(&cp)
|| (0x2E80..=0xA4CF).contains(&cp)
|| (0xAC00..=0xD7A3).contains(&cp)
|| (0xF900..=0xFAFF).contains(&cp)
|| (0xFE10..=0xFE19).contains(&cp)
|| (0xFE30..=0xFE6F).contains(&cp)
|| (0xFF00..=0xFF60).contains(&cp)
|| (0xFFE0..=0xFFE6).contains(&cp)
|| (0x20000..=0x2FFFD).contains(&cp)
|| (0x30000..=0x3FFFD).contains(&cp)
{
2
} else {
1
}
})
.sum()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputAction {
None,
Typed,
CursorMoved,
Submit,
}
+211
View File
@@ -0,0 +1,211 @@
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use syntect::{
easy::HighlightLines,
highlighting::{ThemeSet, Style as SyntectStyle},
parsing::SyntaxSet,
util::LinesWithEndings,
};
pub struct MarkdownRenderer {
syntax_set: SyntaxSet,
theme_set: ThemeSet,
}
impl MarkdownRenderer {
pub fn new() -> Self {
Self {
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
}
}
pub fn render(&self, markdown: &str) -> Vec<Line<'_>> {
let parser = Parser::new(markdown);
let mut lines: Vec<Line> = Vec::new();
let mut current_spans: Vec<Span> = Vec::new();
let mut style_stack: Vec<Style> = Vec::new();
let mut in_code_block: Option<String> = None;
let mut code_block_lang: Option<String> = None;
let mut code_block_content: String = String::new();
let mut in_blockquote = false;
let mut list_depth: usize = 0;
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Strong => {
style_stack.push(Style::default().add_modifier(Modifier::BOLD));
}
Tag::Emphasis => {
style_stack.push(Style::default().add_modifier(Modifier::ITALIC));
}
Tag::CodeBlock(CodeBlockKind::Fenced(lang)) => {
code_block_lang = Some(lang.to_string());
in_code_block = Some(String::new());
}
Tag::CodeBlock(CodeBlockKind::Indented) => {
code_block_lang = None;
in_code_block = Some(String::new());
}
Tag::BlockQuote(_) => {
in_blockquote = true;
}
Tag::List(_) => {
list_depth += 1;
}
Tag::Item => {
let indent = " ".repeat(list_depth.saturating_sub(1));
current_spans.push(Span::raw(format!("{}", indent)));
}
Tag::Paragraph => {}
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Strong | TagEnd::Emphasis => {
style_stack.pop();
}
TagEnd::CodeBlock => {
if let (Some(lang), Some(_)) = (&code_block_lang, in_code_block.take()) {
let highlighted = self.highlight_code_block(&code_block_content, lang);
for line in highlighted {
lines.push(line);
}
} else if !code_block_content.is_empty() {
let code_lines: Vec<String> = code_block_content.lines().map(|s| s.to_string()).collect();
for line in code_lines {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(Color::DarkGray)),
Span::styled(line, Style::default().bg(Color::DarkGray)),
Span::styled(" ", Style::default().bg(Color::DarkGray)),
]));
}
}
code_block_content.clear();
code_block_lang = None;
}
TagEnd::BlockQuote(_) => {
in_blockquote = false;
}
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
}
TagEnd::Item | TagEnd::Paragraph => {
if !current_spans.is_empty() {
let prefix = if in_blockquote {
vec![Span::styled("", Style::default().fg(Color::DarkGray))]
} else {
vec![]
};
let mut spans = prefix;
spans.append(&mut current_spans);
lines.push(Line::from(spans));
} else if in_blockquote {
lines.push(Line::from(Span::styled("", Style::default().fg(Color::DarkGray))));
} else {
lines.push(Line::raw(""));
}
}
_ => {}
},
Event::Text(text) => {
if let Some(ref mut content) = in_code_block {
content.push_str(&text);
} else {
let style = style_stack.last().copied().unwrap_or_default();
current_spans.push(Span::styled(text.to_string(), style));
}
}
Event::Code(code) => {
current_spans.push(Span::styled(
code.to_string(),
Style::default()
.bg(Color::Rgb(40, 40, 40))
.fg(Color::Rgb(248, 248, 242)),
));
}
Event::SoftBreak | Event::HardBreak => {
if in_code_block.is_none() {
let prefix = if in_blockquote {
vec![Span::styled("", Style::default().fg(Color::DarkGray))]
} else {
vec![]
};
let mut spans = prefix;
spans.append(&mut current_spans);
lines.push(Line::from(spans));
}
}
Event::Rule => {
lines.push(Line::from(Span::styled(
"".repeat(40),
Style::default().fg(Color::DarkGray),
)));
}
_ => {}
}
}
if !current_spans.is_empty() {
let prefix = if in_blockquote {
vec![Span::styled("", Style::default().fg(Color::DarkGray))]
} else {
vec![]
};
let mut spans = prefix;
spans.append(&mut current_spans);
lines.push(Line::from(spans));
}
lines
}
fn highlight_code_block(&self, code: &str, lang: &str) -> Vec<Line<'_>> {
let syntax = self
.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let theme = &self.theme_set.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
for line in LinesWithEndings::from(code) {
let highlighted = highlighter.highlight_line(line, &self.syntax_set).unwrap_or_default();
let mut spans = vec![Span::styled(" ", Style::default().bg(Color::Rgb(43, 48, 59)))];
for (style, text) in highlighted {
spans.push(self.syntect_style_to_span(style, text));
}
spans.push(Span::styled(" ", Style::default().bg(Color::Rgb(43, 48, 59))));
lines.push(Line::from(spans));
}
lines
}
fn syntect_style_to_span(&self, style: SyntectStyle, text: &str) -> Span<'_> {
let fg = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
let bg = Color::Rgb(style.background.r, style.background.g, style.background.b);
let mut modifiers = Modifier::empty();
use syntect::highlighting::FontStyle;
if style.font_style.intersects(FontStyle::BOLD) {
modifiers |= Modifier::BOLD;
}
if style.font_style.intersects(FontStyle::ITALIC) {
modifiers |= Modifier::ITALIC;
}
if style.font_style.intersects(FontStyle::UNDERLINE) {
modifiers |= Modifier::UNDERLINED;
}
Span::styled(text.to_string(), Style::default().fg(fg).bg(bg).add_modifier(modifiers))
}
}
impl Default for MarkdownRenderer {
fn default() -> Self {
Self::new()
}
}
+73
View File
@@ -0,0 +1,73 @@
use anyhow::Result;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
pub mod input;
pub mod markdown;
use ratatui::{
backend::CrosstermBackend,
Terminal,
};
use std::io;
use std::panic;
pub struct Tui {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
}
impl Tui {
pub fn new() -> Result<Self> {
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
pub fn enter(&mut self) -> Result<()> {
terminal::enable_raw_mode()?;
crossterm::execute!(
io::stdout(),
EnterAlternateScreen,
EnableMouseCapture
)?;
let panic_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = Self::restore_terminal();
panic_hook(info);
}));
self.terminal.clear()?;
Ok(())
}
pub fn draw<F>(&mut self, f: F) -> Result<()>
where
F: FnOnce(&mut ratatui::Frame),
{
self.terminal.draw(f)?;
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
Self::restore_terminal()?;
Ok(())
}
fn restore_terminal() -> Result<()> {
terminal::disable_raw_mode()?;
crossterm::execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
Ok(())
}
}
impl Drop for Tui {
fn drop(&mut self) {
let _ = Self::restore_terminal();
}
}