diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dc9a7cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Meow - Agent Notes + +Rust terminal AI chat client (TUI). Single crate, no workspace. + +## Build & Run + +```bash +cargo build --release +# binary: target/release/meow +``` + +No tests, no lint/typecheck tooling configured. Just `cargo build` / `cargo run`. + +## Architecture (Single Crate) + +| File | Responsibility | +|------|---------------| +| `src/main.rs` | `tokio::main`, event loop (100ms tick), dispatches to `App` | +| `src/app.rs` | State machine: `Chat` / `ModelSelect` / `ProviderConfig`; draws all UI; spawns stream task | +| `src/config.rs` | `Config` / `ProviderConfig`; loads/saves `~/.meow/config.toml` via `dirs` | +| `src/message.rs` | `Role`, `Message`, `ChatSession` with `uuid` + `chrono` | +| `src/storage.rs` | JSON Lines persistence at `~/.meow/data/sessions/.jsonl` | +| `src/providers/mod.rs` | `Provider` trait (`async_trait`), `Model`, `ProviderError` | +| `src/providers/openai.rs` | `OpenAICompatibleProvider`: `/v1/models`, `/v1/chat/completions` SSE | +| `src/tui/mod.rs` | `Tui`: raw mode, alternate screen, mouse capture, panic hook restore | +| `src/tui/input.rs` | Multi-line `InputState` with cursor movement; `Shift+Enter` newline, `Enter` submit | +| `src/tui/markdown.rs` | `pulldown-cmark` → `ratatui::Line` + `syntect` highlighting (`base16-ocean.dark`) | + +## Key Conventions + +- **Streaming protocol**: `Provider::chat_stream` returns `BoxStream>`. The spawned tokio task sends chunks through an `mpsc::unbounded_channel`. Sentinel `__STREAM_END__` marks completion; `App::poll_stream()` drains the channel on each tick. +- **Provider rebuild**: After saving config, `App::rebuild_providers()` reconstructs the `Vec>` from `config.providers`. Always call this after mutating providers. +- **First-run behavior**: If `config.providers` is empty, app boots into `ProviderConfig` state. `Esc` in that state quits the app instead of returning to chat. +- **Ctrl+C while streaming**: Stops stream and calls `finish_stream_message()` to persist partial response. Do NOT treat as app quit during streaming. +- **Session persistence**: `Storage::append_message` is append-only JSON Lines. `save_current_session()` iterates messages and appends each (safe to call multiple times; duplicates are harmless in current design). +- **Syntax highlighting**: `syntect` uses `regex-fancy` backend (not `onig`). Theme hardcoded to `base16-ocean.dark`. + +## Runtime Data Paths + +- Config: `~/.meow/config.toml` +- Sessions: `~/.meow/data/sessions/.jsonl` + +## Dependencies to Know + +- `reqwest` with `rustls-tls`, `stream` — SSE via `bytes_stream()` +- `ratatui` 0.29 + `crossterm` 0.28 — all TUI rendering +- `syntect` with `default-themes`, `default-syntaxes`, `regex-fancy` +- `pulldown-cmark` 0.12 — markdown parser + +## Adding a New Provider + +Only `OpenAICompatibleProvider` exists. To add a non-OpenAI provider: + +1. Implement `Provider` trait in `src/providers/.rs` +2. Register in `src/providers/mod.rs` +3. Wire instantiation in `App::new()` and `App::rebuild_providers()` diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9d166be..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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/.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>` -- `chat_stream(model, messages) -> Result>>` - -`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). diff --git a/src/app.rs b/src/app.rs index 3ace0b3..2faee9c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,6 +6,7 @@ use crate::{ tui::{input::InputState, markdown::MarkdownRenderer}, }; use anyhow::Result; +use chrono::Utc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, @@ -17,7 +18,9 @@ use ratatui::{ }, Frame, }; +use std::sync::Arc; use tokio::sync::mpsc; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct ProviderForm { @@ -53,6 +56,10 @@ pub enum AppState { form: ProviderForm, editing: Option, }, + SessionSelect { + sessions: Vec<(Uuid, String, usize)>, + selected: usize, + }, } pub struct App { @@ -67,7 +74,7 @@ pub struct App { pub stream_buffer: String, stream_tx: mpsc::UnboundedSender, stream_rx: mpsc::UnboundedReceiver, - pub providers: Vec>, + pub providers: Vec>, pub markdown_renderer: MarkdownRenderer, pub quit: bool, pub show_sidebar: bool, @@ -77,12 +84,12 @@ pub struct App { impl App { pub async fn new() -> Result { let config = Config::load()?; - let providers: Vec> = config + let providers: Vec> = config .providers .iter() .map(|p| { let p = p.clone(); - Box::new(OpenAICompatibleProvider::new(p)) as Box + Arc::new(OpenAICompatibleProvider::new(p)) as Arc }) .collect(); @@ -130,7 +137,7 @@ impl App { .iter() .map(|p| { let p = p.clone(); - Box::new(OpenAICompatibleProvider::new(p)) as Box + Arc::new(OpenAICompatibleProvider::new(p)) as Arc }) .collect(); } @@ -152,12 +159,17 @@ impl App { 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, + AppState::SessionSelect { .. } => self.handle_session_select_key(key).await, } } async fn handle_chat_key(&mut self, key: KeyEvent) -> Result<()> { match (key.code, key.modifiers) { (KeyCode::Char('q'), KeyModifiers::CONTROL) => { + if self.is_streaming { + self.is_streaming = false; + self.finish_stream_message(); + } self.quit = true; } (KeyCode::Char('p'), KeyModifiers::CONTROL) => { @@ -188,13 +200,36 @@ impl App { }; } (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - self.save_current_session()?; + if self.is_streaming { + return Ok(()); + } self.current_session = ChatSession::new("New Chat"); self.messages_scroll = 0; } (KeyCode::Char('b'), KeyModifiers::CONTROL) => { self.show_sidebar = !self.show_sidebar; } + (KeyCode::Char('o'), KeyModifiers::CONTROL) => { + if self.is_streaming { + return Ok(()); + } + match Storage::list_sessions_with_info() { + Ok(sessions) => { + if sessions.is_empty() { + self.status_message = Some("No saved sessions".to_string()); + } else { + self.state = AppState::SessionSelect { + sessions, + selected: 0, + }; + } + } + Err(e) => { + self.status_message = + Some(format!("Failed to list sessions: {}", e)); + } + } + } (KeyCode::Char('r'), KeyModifiers::CONTROL) => { if !self.is_streaming { self.input.clear(); @@ -230,6 +265,7 @@ impl App { { match key.code { KeyCode::Esc => { + self.status_message = None; self.state = AppState::Chat; } KeyCode::Up => { @@ -254,6 +290,7 @@ impl App { } } } + self.status_message = None; self.state = AppState::Chat; } KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE => { @@ -356,6 +393,53 @@ impl App { Ok(()) } + async fn handle_session_select_key(&mut self, key: KeyEvent) -> Result<()> { + if let AppState::SessionSelect { sessions, selected } = &mut self.state { + match key.code { + KeyCode::Esc => { + self.status_message = None; + self.state = AppState::Chat; + } + KeyCode::Up => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down => { + if *selected + 1 < sessions.len() { + *selected += 1; + } + } + KeyCode::Enter => { + let (id, title, ..) = sessions[*selected].clone(); + match Storage::load_session(id) { + Ok(messages) => { + self.current_session = ChatSession { + id, + title, + created_at: messages + .first() + .map(|m| m.created_at) + .unwrap_or_else(Utc::now), + messages, + }; + self.messages_scroll = 0; + self.status_message = None; + self.state = AppState::Chat; + } + Err(e) => { + self.status_message = + Some(format!("Failed to load session: {}", e)); + self.state = AppState::Chat; + } + } + } + _ => {} + } + } + Ok(()) + } + async fn send_message(&mut self) -> Result<()> { let text = self.input.text(); if text.trim().is_empty() { @@ -363,9 +447,9 @@ impl App { } self.input.clear(); - let provider_config = match self.current_provider_idx { + let provider = match self.current_provider_idx { Some(idx) => match self.providers.get(idx) { - Some(p) => p.config().clone(), + Some(p) => p.clone(), None => { self.status_message = Some("No provider configured".to_string()); return Ok(()); @@ -397,8 +481,7 @@ impl App { self.stream_buffer.clear(); tokio::spawn(async move { - let p = OpenAICompatibleProvider::new(provider_config); - match p.chat_stream(&model_clone, &messages).await { + match provider.chat_stream(&model_clone, &messages).await { Ok(mut stream) => { while let Some(result) = stream.next().await { match result { @@ -468,6 +551,10 @@ impl App { self.draw_chat(frame, area); self.draw_provider_config(frame, area); } + AppState::SessionSelect { .. } => { + self.draw_chat(frame, area); + self.draw_session_select(frame, area); + } } } @@ -538,7 +625,9 @@ impl App { .title(" Info ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)); - let paragraph = Paragraph::new(Text::from(items)).block(block); + let paragraph = Paragraph::new(Text::from(items)) + .block(block) + .style(Style::default().bg(Color::Rgb(22, 24, 32))); frame.render_widget(paragraph, area); } @@ -549,6 +638,7 @@ impl App { " Meow AI Chat " }; let header = Paragraph::new("") + .style(Style::default().bg(Color::Rgb(22, 24, 32))) .block( Block::default() .borders(Borders::ALL) @@ -582,7 +672,7 @@ impl App { ))); } - let rendered = self.markdown_renderer.render(&msg.content); + let rendered = self.markdown_renderer.render(&msg.content, area.width); for line in rendered { if align_right { let mut spans = line.spans.clone(); @@ -600,7 +690,7 @@ impl App { "[Assistant]", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), ))); - let rendered = self.markdown_renderer.render(&self.stream_buffer); + let rendered = self.markdown_renderer.render(&self.stream_buffer, area.width); for line in rendered { all_lines.push(line); } @@ -614,6 +704,7 @@ impl App { let paragraph = Paragraph::new(Text::from(all_lines)) .wrap(Wrap { trim: false }) .scroll((scroll as u16, 0)) + .style(Style::default().bg(Color::Rgb(22, 24, 32))) .block(Block::default().borders(Borders::ALL).title(" Messages ")); frame.render_widget(paragraph, area); @@ -639,11 +730,12 @@ impl App { 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" + "Ctrl+P: Models | Ctrl+O: Sessions | Ctrl+S: Config | Ctrl+N: New | Ctrl+Q: Quit" } }); let status_bar = Paragraph::new(Span::styled(status, Style::default().fg(Color::DarkGray))) - .alignment(Alignment::Center); + .alignment(Alignment::Center) + .style(Style::default().bg(Color::Rgb(22, 24, 32))); frame.render_widget(status_bar, area); } @@ -669,7 +761,9 @@ impl App { Style::default().fg(Color::White) }); - let paragraph = Paragraph::new(Text::from(input_text)).block(block); + let paragraph = Paragraph::new(Text::from(input_text)) + .block(block) + .style(Style::default().bg(Color::Rgb(22, 24, 32))); frame.render_widget(paragraph, area); if !self.is_streaming { @@ -777,6 +871,36 @@ impl App { } } + fn draw_session_select(&self, frame: &mut Frame, area: Rect) { + if let AppState::SessionSelect { sessions, selected } = &self.state { + let popup_area = Self::centered_rect(60, 70, area); + frame.render_widget(Clear, popup_area); + + let items: Vec = sessions + .iter() + .enumerate() + .map(|(i, (_, title, count))| { + let style = if i == *selected { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let label = format!(" {} ({} msgs)", title, count); + ListItem::new(label).style(style) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Sessions ")) + .highlight_symbol("> "); + + frame.render_widget(list, popup_area); + } + } + fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 7411d64..2ccee99 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -89,10 +89,11 @@ impl Provider for OpenAICompatibleProvider { result.map_err(ProviderError::Http) }); + let mut line_buffer = String::new(); let parsed = stream - .flat_map(|result| { + .flat_map(move |result| { futures::stream::iter(match result { - Ok(bytes) => parse_sse_chunk(&bytes), + Ok(bytes) => parse_sse_chunk(&bytes, &mut line_buffer), Err(e) => vec![Err(e)], }) }) @@ -102,31 +103,31 @@ impl Provider for OpenAICompatibleProvider { } } -fn parse_sse_chunk(bytes: &[u8]) -> Vec> { - let text = String::from_utf8_lossy(bytes); +fn parse_sse_chunk(bytes: &[u8], buffer: &mut String) -> Vec> { + buffer.push_str(&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::(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())); + while let Some(nl_pos) = buffer.find('\n') { + let line = buffer[..nl_pos].trim().to_string(); + *buffer = buffer[nl_pos + 1..].to_string(); + + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + break; + } + match serde_json::from_str::(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()))); + Err(e) => { + results.push(Err(ProviderError::StreamParse(e.to_string()))); + } } } } diff --git a/src/storage.rs b/src/storage.rs index 41678b6..d367bb5 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,4 +1,4 @@ -use crate::{config::Config, message::Message}; +use crate::{config::Config, message::{Message, Role}}; use anyhow::{Context, Result}; use std::fs::OpenOptions; use std::io::{BufRead, Write}; @@ -75,6 +75,61 @@ impl Storage { Ok(sessions) } + pub fn list_sessions_with_info() -> Result> { + 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 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 title: Option = None; + let mut count = 0; + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + count += 1; + if title.is_none() { + if let Ok(msg) = serde_json::from_str::(&line) { + if msg.role == Role::User { + let t = msg.content.lines().next().unwrap_or("").to_string(); + let t = if t.chars().count() > 60 { + let trunc: String = t.chars().take(60).collect(); + format!("{}...", trunc) + } else { + t + }; + title = Some(t); + } + } + } + } + let title = title.unwrap_or_else(|| { + id.to_string().split('-').next().unwrap_or("unknown").to_string() + }); + sessions.push((id, title, count)); + } + } + } + sessions.sort_by_key(|(_, _, count)| std::cmp::Reverse(*count)); + Ok(sessions) + } + fn count_messages(session_id: Uuid) -> Result { let messages = Self::load_session(session_id)?; Ok(messages.len()) diff --git a/src/tui/markdown.rs b/src/tui/markdown.rs index a3d6bf3..2408659 100644 --- a/src/tui/markdown.rs +++ b/src/tui/markdown.rs @@ -1,4 +1,4 @@ -use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, @@ -10,6 +10,9 @@ use syntect::{ util::LinesWithEndings, }; +const CODE_BG: Color = Color::Rgb(40, 44, 56); +const CODE_BORDER: Color = Color::Rgb(80, 80, 100); + pub struct MarkdownRenderer { syntax_set: SyntaxSet, theme_set: ThemeSet, @@ -23,16 +26,17 @@ impl MarkdownRenderer { } } - pub fn render(&self, markdown: &str) -> Vec> { + pub fn render(&self, markdown: &str, max_area_width: u16) -> Vec> { let parser = Parser::new(markdown); let mut lines: Vec = Vec::new(); let mut current_spans: Vec = Vec::new(); let mut style_stack: Vec