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},
|
tui::{input::InputState, markdown::MarkdownRenderer},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use chrono::Utc;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||||
@@ -17,7 +18,9 @@ use ratatui::{
|
|||||||
},
|
},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProviderForm {
|
pub struct ProviderForm {
|
||||||
@@ -53,6 +56,10 @@ pub enum AppState {
|
|||||||
form: ProviderForm,
|
form: ProviderForm,
|
||||||
editing: Option<usize>,
|
editing: Option<usize>,
|
||||||
},
|
},
|
||||||
|
SessionSelect {
|
||||||
|
sessions: Vec<(Uuid, String, usize)>,
|
||||||
|
selected: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -67,7 +74,7 @@ pub struct App {
|
|||||||
pub stream_buffer: String,
|
pub stream_buffer: String,
|
||||||
stream_tx: mpsc::UnboundedSender<String>,
|
stream_tx: mpsc::UnboundedSender<String>,
|
||||||
stream_rx: mpsc::UnboundedReceiver<String>,
|
stream_rx: mpsc::UnboundedReceiver<String>,
|
||||||
pub providers: Vec<Box<dyn Provider>>,
|
pub providers: Vec<Arc<dyn Provider>>,
|
||||||
pub markdown_renderer: MarkdownRenderer,
|
pub markdown_renderer: MarkdownRenderer,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
pub show_sidebar: bool,
|
pub show_sidebar: bool,
|
||||||
@@ -77,12 +84,12 @@ pub struct App {
|
|||||||
impl App {
|
impl App {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new() -> Result<Self> {
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
let providers: Vec<Box<dyn Provider>> = config
|
let providers: Vec<Arc<dyn Provider>> = config
|
||||||
.providers
|
.providers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let p = p.clone();
|
let p = p.clone();
|
||||||
Box::new(OpenAICompatibleProvider::new(p)) as Box<dyn Provider>
|
Arc::new(OpenAICompatibleProvider::new(p)) as Arc<dyn Provider>
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -130,7 +137,7 @@ impl App {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let p = p.clone();
|
let p = p.clone();
|
||||||
Box::new(OpenAICompatibleProvider::new(p)) as Box<dyn Provider>
|
Arc::new(OpenAICompatibleProvider::new(p)) as Arc<dyn Provider>
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
@@ -152,12 +159,17 @@ impl App {
|
|||||||
AppState::Chat => self.handle_chat_key(key).await,
|
AppState::Chat => self.handle_chat_key(key).await,
|
||||||
AppState::ModelSelect { .. } => self.handle_model_select_key(key).await,
|
AppState::ModelSelect { .. } => self.handle_model_select_key(key).await,
|
||||||
AppState::ProviderConfig { .. } => self.handle_provider_config_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<()> {
|
async fn handle_chat_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
|
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
|
||||||
|
if self.is_streaming {
|
||||||
|
self.is_streaming = false;
|
||||||
|
self.finish_stream_message();
|
||||||
|
}
|
||||||
self.quit = true;
|
self.quit = true;
|
||||||
}
|
}
|
||||||
(KeyCode::Char('p'), KeyModifiers::CONTROL) => {
|
(KeyCode::Char('p'), KeyModifiers::CONTROL) => {
|
||||||
@@ -188,13 +200,36 @@ impl App {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
|
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
|
||||||
self.save_current_session()?;
|
if self.is_streaming {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
self.current_session = ChatSession::new("New Chat");
|
self.current_session = ChatSession::new("New Chat");
|
||||||
self.messages_scroll = 0;
|
self.messages_scroll = 0;
|
||||||
}
|
}
|
||||||
(KeyCode::Char('b'), KeyModifiers::CONTROL) => {
|
(KeyCode::Char('b'), KeyModifiers::CONTROL) => {
|
||||||
self.show_sidebar = !self.show_sidebar;
|
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) => {
|
(KeyCode::Char('r'), KeyModifiers::CONTROL) => {
|
||||||
if !self.is_streaming {
|
if !self.is_streaming {
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
@@ -230,6 +265,7 @@ impl App {
|
|||||||
{
|
{
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
self.status_message = None;
|
||||||
self.state = AppState::Chat;
|
self.state = AppState::Chat;
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
@@ -254,6 +290,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.status_message = None;
|
||||||
self.state = AppState::Chat;
|
self.state = AppState::Chat;
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE => {
|
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE => {
|
||||||
@@ -356,6 +393,53 @@ impl App {
|
|||||||
Ok(())
|
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<()> {
|
async fn send_message(&mut self) -> Result<()> {
|
||||||
let text = self.input.text();
|
let text = self.input.text();
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
@@ -363,9 +447,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.input.clear();
|
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(idx) => match self.providers.get(idx) {
|
||||||
Some(p) => p.config().clone(),
|
Some(p) => p.clone(),
|
||||||
None => {
|
None => {
|
||||||
self.status_message = Some("No provider configured".to_string());
|
self.status_message = Some("No provider configured".to_string());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -397,8 +481,7 @@ impl App {
|
|||||||
self.stream_buffer.clear();
|
self.stream_buffer.clear();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let p = OpenAICompatibleProvider::new(provider_config);
|
match provider.chat_stream(&model_clone, &messages).await {
|
||||||
match p.chat_stream(&model_clone, &messages).await {
|
|
||||||
Ok(mut stream) => {
|
Ok(mut stream) => {
|
||||||
while let Some(result) = stream.next().await {
|
while let Some(result) = stream.next().await {
|
||||||
match result {
|
match result {
|
||||||
@@ -468,6 +551,10 @@ impl App {
|
|||||||
self.draw_chat(frame, area);
|
self.draw_chat(frame, area);
|
||||||
self.draw_provider_config(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 ")
|
.title(" Info ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::DarkGray));
|
.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);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +638,7 @@ impl App {
|
|||||||
" Meow AI Chat "
|
" Meow AI Chat "
|
||||||
};
|
};
|
||||||
let header = Paragraph::new("")
|
let header = Paragraph::new("")
|
||||||
|
.style(Style::default().bg(Color::Rgb(22, 24, 32)))
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.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 {
|
for line in rendered {
|
||||||
if align_right {
|
if align_right {
|
||||||
let mut spans = line.spans.clone();
|
let mut spans = line.spans.clone();
|
||||||
@@ -600,7 +690,7 @@ impl App {
|
|||||||
"[Assistant]",
|
"[Assistant]",
|
||||||
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
|
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 {
|
for line in rendered {
|
||||||
all_lines.push(line);
|
all_lines.push(line);
|
||||||
}
|
}
|
||||||
@@ -614,6 +704,7 @@ impl App {
|
|||||||
let paragraph = Paragraph::new(Text::from(all_lines))
|
let paragraph = Paragraph::new(Text::from(all_lines))
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((scroll as u16, 0))
|
.scroll((scroll as u16, 0))
|
||||||
|
.style(Style::default().bg(Color::Rgb(22, 24, 32)))
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Messages "));
|
.block(Block::default().borders(Borders::ALL).title(" Messages "));
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
@@ -639,11 +730,12 @@ impl App {
|
|||||||
if self.is_streaming {
|
if self.is_streaming {
|
||||||
"Press Ctrl+C to stop streaming"
|
"Press Ctrl+C to stop streaming"
|
||||||
} else {
|
} 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)))
|
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);
|
frame.render_widget(status_bar, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +761,9 @@ impl App {
|
|||||||
Style::default().fg(Color::White)
|
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);
|
frame.render_widget(paragraph, area);
|
||||||
|
|
||||||
if !self.is_streaming {
|
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 {
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
let popup_layout = Layout::default()
|
let popup_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
|
|||||||
+23
-22
@@ -89,10 +89,11 @@ impl Provider for OpenAICompatibleProvider {
|
|||||||
result.map_err(ProviderError::Http)
|
result.map_err(ProviderError::Http)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut line_buffer = String::new();
|
||||||
let parsed = stream
|
let parsed = stream
|
||||||
.flat_map(|result| {
|
.flat_map(move |result| {
|
||||||
futures::stream::iter(match 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)],
|
Err(e) => vec![Err(e)],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -102,31 +103,31 @@ impl Provider for OpenAICompatibleProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_sse_chunk(bytes: &[u8]) -> Vec<Result<String, ProviderError>> {
|
fn parse_sse_chunk(bytes: &[u8], buffer: &mut String) -> Vec<Result<String, ProviderError>> {
|
||||||
let text = String::from_utf8_lossy(bytes);
|
buffer.push_str(&String::from_utf8_lossy(bytes));
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
for line in text.lines() {
|
while let Some(nl_pos) = buffer.find('\n') {
|
||||||
let line = line.trim();
|
let line = buffer[..nl_pos].trim().to_string();
|
||||||
if !line.starts_with("data: ") {
|
*buffer = buffer[nl_pos + 1..].to_string();
|
||||||
continue;
|
|
||||||
}
|
if let Some(data) = line.strip_prefix("data: ") {
|
||||||
let data = &line[6..];
|
if data == "[DONE]" {
|
||||||
if data == "[DONE]" {
|
break;
|
||||||
break;
|
}
|
||||||
}
|
match serde_json::from_str::<ChatCompletionChunk>(data) {
|
||||||
match serde_json::from_str::<ChatCompletionChunk>(data) {
|
Ok(chunk) => {
|
||||||
Ok(chunk) => {
|
if let Some(choice) = chunk.choices.first() {
|
||||||
if let Some(choice) = chunk.choices.first() {
|
if let Some(content) = &choice.delta.content {
|
||||||
if let Some(content) = &choice.delta.content {
|
if !content.is_empty() {
|
||||||
if !content.is_empty() {
|
results.push(Ok(content.clone()));
|
||||||
results.push(Ok(content.clone()));
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
results.push(Err(ProviderError::StreamParse(e.to_string())));
|
||||||
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 anyhow::{Context, Result};
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::{BufRead, Write};
|
use std::io::{BufRead, Write};
|
||||||
@@ -75,6 +75,61 @@ impl Storage {
|
|||||||
Ok(sessions)
|
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> {
|
fn count_messages(session_id: Uuid) -> Result<usize> {
|
||||||
let messages = Self::load_session(session_id)?;
|
let messages = Self::load_session(session_id)?;
|
||||||
Ok(messages.len())
|
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::{
|
use ratatui::{
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
@@ -10,6 +10,9 @@ use syntect::{
|
|||||||
util::LinesWithEndings,
|
util::LinesWithEndings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CODE_BG: Color = Color::Rgb(40, 44, 56);
|
||||||
|
const CODE_BORDER: Color = Color::Rgb(80, 80, 100);
|
||||||
|
|
||||||
pub struct MarkdownRenderer {
|
pub struct MarkdownRenderer {
|
||||||
syntax_set: SyntaxSet,
|
syntax_set: SyntaxSet,
|
||||||
theme_set: ThemeSet,
|
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 parser = Parser::new(markdown);
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
let mut current_spans: Vec<Span> = Vec::new();
|
let mut current_spans: Vec<Span> = Vec::new();
|
||||||
let mut style_stack: Vec<Style> = 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_lang: Option<String> = None;
|
||||||
let mut code_block_content: String = String::new();
|
let mut code_block_content: String = String::new();
|
||||||
let mut in_blockquote = false;
|
let mut in_blockquote = false;
|
||||||
let mut list_depth: usize = 0;
|
let mut list_depth: usize = 0;
|
||||||
|
let code_max_width = (max_area_width as usize).saturating_sub(6);
|
||||||
|
|
||||||
for event in parser {
|
for event in parser {
|
||||||
match event {
|
match event {
|
||||||
@@ -45,11 +49,13 @@ impl MarkdownRenderer {
|
|||||||
}
|
}
|
||||||
Tag::CodeBlock(CodeBlockKind::Fenced(lang)) => {
|
Tag::CodeBlock(CodeBlockKind::Fenced(lang)) => {
|
||||||
code_block_lang = Some(lang.to_string());
|
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) => {
|
Tag::CodeBlock(CodeBlockKind::Indented) => {
|
||||||
code_block_lang = None;
|
code_block_lang = None;
|
||||||
in_code_block = Some(String::new());
|
code_block_content.clear();
|
||||||
|
in_code_block = true;
|
||||||
}
|
}
|
||||||
Tag::BlockQuote(_) => {
|
Tag::BlockQuote(_) => {
|
||||||
in_blockquote = true;
|
in_blockquote = true;
|
||||||
@@ -62,30 +68,81 @@ impl MarkdownRenderer {
|
|||||||
current_spans.push(Span::raw(format!("{}• ", indent)));
|
current_spans.push(Span::raw(format!("{}• ", indent)));
|
||||||
}
|
}
|
||||||
Tag::Paragraph => {}
|
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 {
|
Event::End(tag_end) => match tag_end {
|
||||||
TagEnd::Strong | TagEnd::Emphasis => {
|
TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough => {
|
||||||
style_stack.pop();
|
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 => {
|
TagEnd::CodeBlock => {
|
||||||
if let (Some(lang), Some(_)) = (&code_block_lang, in_code_block.take()) {
|
if let Some(lang) = &code_block_lang {
|
||||||
let highlighted = self.highlight_code_block(&code_block_content, lang);
|
if in_code_block {
|
||||||
for line in highlighted {
|
let highlighted = self.highlight_code_block(&code_block_content, lang, code_max_width);
|
||||||
lines.push(line);
|
for line in highlighted {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if !code_block_content.is_empty() {
|
} 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 {
|
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![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(" ", Style::default().bg(Color::DarkGray)),
|
Span::styled("│ ", Style::default().fg(CODE_BORDER).bg(CODE_BG)),
|
||||||
Span::styled(line, Style::default().bg(Color::DarkGray)),
|
Span::styled(line.to_string(), Style::default().bg(CODE_BG)),
|
||||||
Span::styled(" ", Style::default().bg(Color::DarkGray)),
|
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_content.clear();
|
||||||
code_block_lang = None;
|
code_block_lang = None;
|
||||||
|
in_code_block = false;
|
||||||
}
|
}
|
||||||
TagEnd::BlockQuote(_) => {
|
TagEnd::BlockQuote(_) => {
|
||||||
in_blockquote = false;
|
in_blockquote = false;
|
||||||
@@ -112,10 +169,12 @@ impl MarkdownRenderer {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Event::Text(text) => {
|
Event::Text(text) => {
|
||||||
if let Some(ref mut content) = in_code_block {
|
if in_code_block {
|
||||||
content.push_str(&text);
|
code_block_content.push_str(&text);
|
||||||
} else {
|
} 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));
|
current_spans.push(Span::styled(text.to_string(), style));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +187,7 @@ impl MarkdownRenderer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Event::SoftBreak | Event::HardBreak => {
|
Event::SoftBreak | Event::HardBreak => {
|
||||||
if in_code_block.is_none() {
|
if !in_code_block {
|
||||||
let prefix = if in_blockquote {
|
let prefix = if in_blockquote {
|
||||||
vec![Span::styled("│ ", Style::default().fg(Color::DarkGray))]
|
vec![Span::styled("│ ", Style::default().fg(Color::DarkGray))]
|
||||||
} else {
|
} else {
|
||||||
@@ -163,7 +222,7 @@ impl MarkdownRenderer {
|
|||||||
lines
|
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
|
let syntax = self
|
||||||
.syntax_set
|
.syntax_set
|
||||||
.find_syntax_by_token(lang)
|
.find_syntax_by_token(lang)
|
||||||
@@ -173,16 +232,47 @@ impl MarkdownRenderer {
|
|||||||
let mut highlighter = HighlightLines::new(syntax, theme);
|
let mut highlighter = HighlightLines::new(syntax, theme);
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
for line in LinesWithEndings::from(code) {
|
let raw_lines: Vec<&str> = LinesWithEndings::from(code).collect();
|
||||||
let highlighted = highlighter.highlight_line(line, &self.syntax_set).unwrap_or_default();
|
let label = format!(" {} ", lang);
|
||||||
let mut spans = vec![Span::styled(" ", Style::default().bg(Color::Rgb(43, 48, 59)))];
|
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 {
|
for (style, text) in highlighted {
|
||||||
spans.push(self.syntect_style_to_span(style, text));
|
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));
|
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
|
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 {
|
impl Default for MarkdownRenderer {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|||||||
Reference in New Issue
Block a user