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
+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())));
}
}
}
}