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:
2026-05-12 23:25:17 +08:00
parent 6e1eccc5e2
commit ebc20e0013
6 changed files with 410 additions and 204 deletions
+56
View File
@@ -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()`
-143
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()