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