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:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
/.idea
|
||||
*.iml
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+22
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user