fix: SSE line buffering, provider trait plumbing, streaming safety, session resume, code block boxes, markdown improvements
Bug fixes: - Fix SSE chunk parsing: buffer partial lines across network boundaries (openai.rs) - Use Arc<dyn Provider> so send_message calls through the trait and reuses the HTTP connection pool, instead of hardcoding OpenAICompatibleProvider per request (app.rs) - Fix Ctrl+Q during streaming: persist partial response before quitting (app.rs) - Fix Ctrl+N duplicate JSONL messages: remove redundant save_current_session call and guard against new session while streaming (app.rs) - Fix code block content never rendered: text was accumulated into the wrong variable (markdown.rs) - Fix status bar stuck on 'Loading models...' after closing model selector (app.rs) - Fix CJK character panic in session title truncation: use char-level slicing (storage.rs) New features: - Session resume with Ctrl+O: list, browse, and load past chat sessions (app.rs, storage.rs) - Code block boxes: full-border rendering (top/left/right/bottom) with solid background for acrylic/transparent terminals (markdown.rs) - Code blocks fill chat area width with CJK-aware display width padding (markdown.rs, app.rs) - Markdown: add heading support (H1-H6), strikethrough, fix bold+italic by combining style stack layers (markdown.rs) Docs: - Replace CLAUDE.md with AGENTS.md containing repo-specific agent guidance
This commit is contained in:
@@ -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/<uuid>.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<Result<String>>`. 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<Box<dyn Provider>>` 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/<uuid>.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/<name>.rs`
|
||||
2. Register in `src/providers/mod.rs`
|
||||
3. Wire instantiation in `App::new()` and `App::rebuild_providers()`
|
||||
@@ -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/<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).
|
||||
+139
-15
@@ -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<usize>,
|
||||
},
|
||||
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<String>,
|
||||
stream_rx: mpsc::UnboundedReceiver<String>,
|
||||
pub providers: Vec<Box<dyn Provider>>,
|
||||
pub providers: Vec<Arc<dyn Provider>>,
|
||||
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<Self> {
|
||||
let config = Config::load()?;
|
||||
let providers: Vec<Box<dyn Provider>> = config
|
||||
let providers: Vec<Arc<dyn Provider>> = config
|
||||
.providers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.clone();
|
||||
Box::new(OpenAICompatibleProvider::new(p)) as Box<dyn Provider>
|
||||
Arc::new(OpenAICompatibleProvider::new(p)) as Arc<dyn Provider>
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -130,7 +137,7 @@ impl App {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.clone();
|
||||
Box::new(OpenAICompatibleProvider::new(p)) as Box<dyn Provider>
|
||||
Arc::new(OpenAICompatibleProvider::new(p)) as Arc<dyn Provider>
|
||||
})
|
||||
.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<ListItem> = 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)
|
||||
|
||||
+23
-22
@@ -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<Result<String, ProviderError>> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
fn parse_sse_chunk(bytes: &[u8], buffer: &mut String) -> Vec<Result<String, ProviderError>> {
|
||||
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::<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()));
|
||||
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::<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())));
|
||||
Err(e) => {
|
||||
results.push(Err(ProviderError::StreamParse(e.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-1
@@ -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<Vec<(Uuid, String, 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 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<String> = 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::<Message>(&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<usize> {
|
||||
let messages = Self::load_session(session_id)?;
|
||||
Ok(messages.len())
|
||||
|
||||
+136
-23
@@ -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<Line<'_>> {
|
||||
pub fn render(&self, markdown: &str, max_area_width: u16) -> 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 in_code_block = false;
|
||||
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;
|
||||
let code_max_width = (max_area_width as usize).saturating_sub(6);
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
@@ -45,11 +49,13 @@ impl MarkdownRenderer {
|
||||
}
|
||||
Tag::CodeBlock(CodeBlockKind::Fenced(lang)) => {
|
||||
code_block_lang = Some(lang.to_string());
|
||||
in_code_block = Some(String::new());
|
||||
code_block_content.clear();
|
||||
in_code_block = true;
|
||||
}
|
||||
Tag::CodeBlock(CodeBlockKind::Indented) => {
|
||||
code_block_lang = None;
|
||||
in_code_block = Some(String::new());
|
||||
code_block_content.clear();
|
||||
in_code_block = true;
|
||||
}
|
||||
Tag::BlockQuote(_) => {
|
||||
in_blockquote = true;
|
||||
@@ -62,30 +68,81 @@ impl MarkdownRenderer {
|
||||
current_spans.push(Span::raw(format!("{}• ", indent)));
|
||||
}
|
||||
Tag::Paragraph => {}
|
||||
Tag::Heading { level, .. } => {
|
||||
match level {
|
||||
HeadingLevel::H1 => style_stack.push(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
),
|
||||
HeadingLevel::H2 => style_stack
|
||||
.push(Style::default().add_modifier(Modifier::BOLD)),
|
||||
_ => style_stack
|
||||
.push(Style::default().add_modifier(Modifier::BOLD)),
|
||||
}
|
||||
}
|
||||
Tag::Strikethrough => {
|
||||
style_stack.push(Style::default().add_modifier(Modifier::CROSSED_OUT));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag_end) => match tag_end {
|
||||
TagEnd::Strong | TagEnd::Emphasis => {
|
||||
TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough => {
|
||||
style_stack.pop();
|
||||
}
|
||||
TagEnd::Heading(_) => {
|
||||
style_stack.pop();
|
||||
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.push(Line::raw(""));
|
||||
}
|
||||
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);
|
||||
if let Some(lang) = &code_block_lang {
|
||||
if in_code_block {
|
||||
let highlighted = self.highlight_code_block(&code_block_content, lang, code_max_width);
|
||||
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();
|
||||
let code_lines: Vec<&str> = code_block_content.lines().collect();
|
||||
let inner_width = code_max_width.max(4);
|
||||
|
||||
let top_fill = inner_width.saturating_sub(6); // " code " = 6
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("┌─ code {}─┐", "─".repeat(top_fill)),
|
||||
Style::default().fg(CODE_BORDER).bg(CODE_BG),
|
||||
)));
|
||||
|
||||
for line in code_lines {
|
||||
let disp_w = display_width(line);
|
||||
let padding = " ".repeat(inner_width.saturating_sub(disp_w));
|
||||
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)),
|
||||
Span::styled("│ ", Style::default().fg(CODE_BORDER).bg(CODE_BG)),
|
||||
Span::styled(line.to_string(), Style::default().bg(CODE_BG)),
|
||||
Span::styled(padding, Style::default().bg(CODE_BG)),
|
||||
Span::styled(" │", Style::default().fg(CODE_BORDER).bg(CODE_BG)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("└{}─┘", "─".repeat(inner_width + 1)),
|
||||
Style::default().fg(CODE_BORDER).bg(CODE_BG),
|
||||
)));
|
||||
}
|
||||
code_block_content.clear();
|
||||
code_block_lang = None;
|
||||
in_code_block = false;
|
||||
}
|
||||
TagEnd::BlockQuote(_) => {
|
||||
in_blockquote = false;
|
||||
@@ -112,10 +169,12 @@ impl MarkdownRenderer {
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
if let Some(ref mut content) = in_code_block {
|
||||
content.push_str(&text);
|
||||
if in_code_block {
|
||||
code_block_content.push_str(&text);
|
||||
} else {
|
||||
let style = style_stack.last().copied().unwrap_or_default();
|
||||
let style = style_stack
|
||||
.iter()
|
||||
.fold(Style::default(), |acc, s| acc.patch(*s));
|
||||
current_spans.push(Span::styled(text.to_string(), style));
|
||||
}
|
||||
}
|
||||
@@ -128,7 +187,7 @@ impl MarkdownRenderer {
|
||||
));
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => {
|
||||
if in_code_block.is_none() {
|
||||
if !in_code_block {
|
||||
let prefix = if in_blockquote {
|
||||
vec![Span::styled("│ ", Style::default().fg(Color::DarkGray))]
|
||||
} else {
|
||||
@@ -163,7 +222,7 @@ impl MarkdownRenderer {
|
||||
lines
|
||||
}
|
||||
|
||||
fn highlight_code_block(&self, code: &str, lang: &str) -> Vec<Line<'_>> {
|
||||
fn highlight_code_block(&self, code: &str, lang: &str, max_area_width: usize) -> Vec<Line<'_>> {
|
||||
let syntax = self
|
||||
.syntax_set
|
||||
.find_syntax_by_token(lang)
|
||||
@@ -173,16 +232,47 @@ impl MarkdownRenderer {
|
||||
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)))];
|
||||
let raw_lines: Vec<&str> = LinesWithEndings::from(code).collect();
|
||||
let label = format!(" {} ", lang);
|
||||
let label_width = label.chars().count();
|
||||
let inner_width = max_area_width.max(label_width + 2);
|
||||
|
||||
let top_fill = inner_width.saturating_sub(label_width);
|
||||
let top = format!("┌─{}{}─┐", label, "─".repeat(top_fill));
|
||||
lines.push(Line::from(Span::styled(
|
||||
top,
|
||||
Style::default().fg(CODE_BORDER).bg(CODE_BG),
|
||||
)));
|
||||
|
||||
for raw in raw_lines {
|
||||
let content = raw.strip_suffix('\n').unwrap_or(raw);
|
||||
let disp_w = display_width(content);
|
||||
let padding = " ".repeat(inner_width.saturating_sub(disp_w));
|
||||
|
||||
let highlighted = highlighter.highlight_line(raw, &self.syntax_set).unwrap_or_default();
|
||||
let mut spans = vec![Span::styled(
|
||||
"│ ",
|
||||
Style::default().fg(CODE_BORDER).bg(CODE_BG),
|
||||
)];
|
||||
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))));
|
||||
if inner_width > disp_w {
|
||||
spans.push(Span::styled(padding, Style::default().bg(CODE_BG)));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
" │",
|
||||
Style::default().fg(CODE_BORDER).bg(CODE_BG),
|
||||
));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
let bottom = format!("└{}─┘", "─".repeat(inner_width + 1));
|
||||
lines.push(Line::from(Span::styled(
|
||||
bottom,
|
||||
Style::default().fg(CODE_BORDER).bg(CODE_BG),
|
||||
)));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -204,6 +294,29 @@ impl MarkdownRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_width(s: &str) -> usize {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
let cp = c as u32;
|
||||
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()
|
||||
}
|
||||
|
||||
impl Default for MarkdownRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
||||
Reference in New Issue
Block a user