Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54484ed137 | ||
|
|
9bf5236680 | ||
|
|
4428e8bc01 | ||
|
|
b2e848d124 |
@@ -1,3 +1,11 @@
|
|||||||
|
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add mouse capture functionality to terminal and enhance markdown rendering with table support ([4428e8b](https://github.com/asepharyana/zesdex/commit/4428e8bc01c196415ac408a42f57305227a79760))
|
||||||
|
* Improve markdown rendering with enhanced line wrapping and indentation for code blocks ([b2e848d](https://github.com/asepharyana/zesdex/commit/b2e848d124e726c4d8b644d473e518398fab1dea))
|
||||||
|
|
||||||
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)
|
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -4436,7 +4436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex"
|
name = "zesdex"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "zesdex"
|
name = "zesdex"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~750 -->
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
Zesdex is a single-process terminal AI coding agent with optional daemon/client split.
|
|
||||||
|
|
||||||
## System Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────┐
|
|
||||||
│ main.rs │
|
|
||||||
│ single-process ─┬── daemon ── Unix socket ── client │
|
|
||||||
│ └── attach <id> (TUI-only client) │
|
|
||||||
└──────────────────────┬───────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────────────▼───────────────────────────────┐
|
|
||||||
│ Event Loop │
|
|
||||||
│ ┌────────┐ ┌───────────┐ ┌──────┐ ┌────────┐ │
|
|
||||||
│ │Input │──▶│ Actions │──▶│State │──▶│ TUI │ │
|
|
||||||
│ │Handler │ │ (dispatch)│ │ │ │ Render │ │
|
|
||||||
│ └────────┘ └─────┬─────┘ └──────┘ └────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────▼──────┐ │
|
|
||||||
│ │ LLM Stream │ │
|
|
||||||
│ │ + Tool Exec │ │
|
|
||||||
│ └──────┬──────┘ │
|
|
||||||
│ ┌────┴────┐ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌─────▼──┐ ┌───▼────┐ │
|
|
||||||
│ │ Tools │ │Sub- │ │
|
|
||||||
│ │ (37) │ │agents │ │
|
|
||||||
│ └────────┘ └────────┘ │
|
|
||||||
└───────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User keystroke → Controller (KeyEvent → Action)
|
|
||||||
→ apply_action() mutates AppStateRest
|
|
||||||
→ TUI redraws (ratatui Frame)
|
|
||||||
→ On submit: LLM request → SSE stream → tool calls → tool results → more LLM
|
|
||||||
→ Session persisted to disk (editlog, msglog, memory)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Process Modes
|
|
||||||
|
|
||||||
| Mode | Impl | Process | IPC |
|
|
||||||
|------|------|---------|-----|
|
|
||||||
| Single | `run_single_process()` | One | No |
|
|
||||||
| Daemon | `run_daemon()` | Server | `ipc/server.rs` |
|
|
||||||
| Attach | `run_attach()` | Client | `ipc/client.rs` |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Lines | Role |
|
|
||||||
|------|-------|------|
|
|
||||||
| `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
|
|
||||||
| `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
|
|
||||||
| `src/controller/input.rs` | 365 | Key event → Action mapping |
|
|
||||||
| `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~850 -->
|
|
||||||
|
|
||||||
# Backend / Service Layer
|
|
||||||
|
|
||||||
## AI Provider
|
|
||||||
|
|
||||||
`src/service/provider.rs` (310 lines)
|
|
||||||
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
|
|
||||||
- `chat_with_tools()` — non-streaming with tool definitions
|
|
||||||
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
|
|
||||||
- Retry logic: up to 3 attempts on transient errors, exponential backoff
|
|
||||||
|
|
||||||
## OAuth
|
|
||||||
|
|
||||||
`src/service/oauth/manager.rs` (113 lines) + `loopback.rs` + `pkce.rs`
|
|
||||||
- PKCE flow: `CodeVerifier` → challenge → browser auth → loopback server → token exchange
|
|
||||||
- Configurable via `app_config.json` provider definitions (auth URL, token URL, scopes)
|
|
||||||
|
|
||||||
## IPC / Daemon
|
|
||||||
|
|
||||||
`src/ipc/` (7 files, ~350 lines total)
|
|
||||||
- Unix domain socket, length-prefixed JSON frames
|
|
||||||
- Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
|
|
||||||
- Clients send `ClientRequest` (key presses, resize, submit, scroll)
|
|
||||||
- State sync uses full-state push from daemon to client after each action
|
|
||||||
|
|
||||||
## Workflow Engine
|
|
||||||
|
|
||||||
`src/app/workflow/engine.rs` (648 lines) + `script.rs`
|
|
||||||
- Inline JS-style DSL executed by a lightweight runtime
|
|
||||||
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
|
|
||||||
- Max concurrency configurable via `workflow_max_concurrency` setting
|
|
||||||
- Hive-mind orchestrator in `hive_mind.rs`: Core Intelligence compiles a `CognitiveCyclePlan` per task — cycle count and nodes-per-cycle are decided fresh each time based on what the task actually needs
|
|
||||||
|
|
||||||
## Sub-Agent System
|
|
||||||
|
|
||||||
`src/app/subagent/` (6 files: `spawn.rs`, `engine.rs`, `context.rs`, `event.rs`, `division.rs`, `auto.rs`, ~450 lines total)
|
|
||||||
- `run_subagent()` — spawns independent agent with its own tool set and context
|
|
||||||
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
|
|
||||||
- Uses `LlmClient` (same as main agent) with tool-use API
|
|
||||||
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
|
|
||||||
- Node access tiers (`division.rs`'s `tool_scope` module): `read`, `write`, `full` — granted per node by the Core Intelligence based on what its directive needs
|
|
||||||
|
|
||||||
## MCP Client
|
|
||||||
|
|
||||||
`src/app/mcp/manager.rs` (441+ lines)
|
|
||||||
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout
|
|
||||||
- HTTP transport: streaming HTTP with JSON-RPC
|
|
||||||
- Dynamic tool list refresh and error recovery
|
|
||||||
- Persistent child handle for stdio (reuses connection across calls)
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
`src/app/review/mod.rs` (495 lines)
|
|
||||||
- Post-tool execution quality check against learned lessons
|
|
||||||
- Invokes `run_subagent()` with reviewer prompt
|
|
||||||
- Staleness detection: skips review after N consecutive empty results
|
|
||||||
- Three review types: code quality, architecture, security
|
|
||||||
|
|
||||||
## Background Bash
|
|
||||||
|
|
||||||
`src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
|
|
||||||
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
|
|
||||||
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
|
|
||||||
- Killable via PID (SIGTERM)
|
|
||||||
- Output buffering capped at 10,000 lines to prevent memory issues
|
|
||||||
|
|
||||||
## Gate Guard / Harness
|
|
||||||
|
|
||||||
`src/app/harness.rs` (495 lines)
|
|
||||||
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
|
|
||||||
- Path traversal, credential read, and destructive command detection
|
|
||||||
- Pattern detection for stub code, denial language, and assumptions in write/edit content
|
|
||||||
- Reason validation for mutating tools (minimum 8 characters, rejects generic non-answers)
|
|
||||||
- Includes 8 unit tests for verdict parsing formats
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~600 -->
|
|
||||||
|
|
||||||
# Data / Persistence Layer
|
|
||||||
|
|
||||||
## Storage Overview
|
|
||||||
|
|
||||||
Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.config/zesdex/
|
|
||||||
├── settings.json # User preferences (provider, model, tokens)
|
|
||||||
├── app_config.json # Provider definitions (API base, auth, models)
|
|
||||||
├── agents/ # Global agent definitions
|
|
||||||
│ └── *.json
|
|
||||||
├── memory/ # Persistent lesson/reference store
|
|
||||||
│ └── *.md # Markdown with YAML frontmatter
|
|
||||||
├── sessions/ # Per-session data
|
|
||||||
│ └── <session-uuid>/
|
|
||||||
│ ├── edits.jsonl # Edit history (JSONL, append-only)
|
|
||||||
│ ├── msglog.db # SQLite message log
|
|
||||||
│ ├── transcript.json # Chat transcript
|
|
||||||
│ ├── session.json # Session metadata
|
|
||||||
│ ├── agents.json # Session-local agent defs
|
|
||||||
│ └── snapshot.dat # State snapshot (daemon mode)
|
|
||||||
├── run/ # Unix domain sockets
|
|
||||||
│ └── zesdex-*.sock
|
|
||||||
└── store.json # Legacy session index
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Lines | Role |
|
|
||||||
|------|-------|------|
|
|
||||||
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) |
|
|
||||||
| `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
|
|
||||||
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
|
|
||||||
| `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
|
|
||||||
| `src/model/editlog.rs` | 161 | Edit log — append-only JSONL (not JSON array) |
|
|
||||||
| `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
|
|
||||||
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
|
|
||||||
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
|
|
||||||
| `src/model/agent_def/` | 3 files | Agent definitions (builtin, global, session-local) |
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
- **No ORM** — raw JSON files + SQLite via rusqlite
|
|
||||||
- **settings.json** — loaded at startup, saved on quit / mode switches
|
|
||||||
- **Memory format** — Markdown files with YAML frontmatter (`---\nname: ...\ndescription: ...\n---\ncontent`)
|
|
||||||
- **Edit log** — append-only, stores `(file, old, new, timestamp, tool)`
|
|
||||||
- **Session locking** — flock-based, prevents concurrent access to same session dir
|
|
||||||
- **Message log** — SQLite with attached blobs for tool arguments/outputs
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~400 -->
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
|
|
||||||
## Rust Crates (Cargo.toml)
|
|
||||||
|
|
||||||
| Crate | Version | Purpose |
|
|
||||||
|-------|---------|---------|
|
|
||||||
| ratatui | 0.30 | TUI framework (tui-rs successor) |
|
|
||||||
| crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
|
|
||||||
| tokio | 1 | Async runtime (daemon, OAuth loopback) |
|
|
||||||
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
|
|
||||||
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
|
|
||||||
| serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
|
|
||||||
| anyhow | 1 | Error handling (no custom error types) |
|
|
||||||
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
|
|
||||||
| rusqlite | 0.40 | SQLite (bundled, for message log) |
|
|
||||||
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
|
|
||||||
| syntect | 5 | Syntax highlighting (code blocks in chat) |
|
|
||||||
| sha2 | 0.10 | SHA-256 for PKCE challenge |
|
|
||||||
| base64 | 0.22 | URL-safe base64 for PKCE |
|
|
||||||
| libc | 0.2 | daemon PID file locking |
|
|
||||||
| rmcp | 2.2 | MCP client (stdio + HTTP transports) |
|
|
||||||
| uuid | 1 | Session IDs, job IDs |
|
|
||||||
| chrono | 0.4 | Timestamps (ISO 8601, millis) |
|
|
||||||
| dirs | 6 | Platform data directories |
|
|
||||||
| dom_smoothie | 0.18 | HTML → plain text (web scraping) |
|
|
||||||
| scraper | 0.27 | HTML parsing (web scraping) |
|
|
||||||
| ignore | 0.4 | .gitignore-aware file walking (glob tool) |
|
|
||||||
| regex / globset | 0.4 | Pattern matching (grep/glob tools) |
|
|
||||||
| url / percent-encoding | 2 | URL parsing + encoding (OAuth) |
|
|
||||||
|
|
||||||
## External Services
|
|
||||||
|
|
||||||
| Service | Integration | Notes |
|
|
||||||
|---------|-------------|-------|
|
|
||||||
| **LLM providers** | HTTP API (OpenAI-compatible) | Configurable via app_config.json |
|
|
||||||
| **MCP servers** | stdio or HTTP | Model Context Protocol |
|
|
||||||
| **git** | CLI (spawns `git`) | Via git_operator/git_worktree/git_cred tools |
|
|
||||||
| **sh** | CLI (spawns `sh`) | Via bash tool |
|
|
||||||
| **webbrowser** | opens URL | OAuth browser flow |
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~700 -->
|
|
||||||
|
|
||||||
# Frontend / TUI
|
|
||||||
|
|
||||||
## Render Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
ratatui::Terminal::draw(|frame|)
|
|
||||||
→ view::draw(frame, AppStateRest)
|
|
||||||
→ render_main_panel / render_overlay (based on overlay state)
|
|
||||||
→ render_input_bar
|
|
||||||
→ draw_status_bar
|
|
||||||
→ render_toasts (top-right floating notifications)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────┐
|
|
||||||
│ Chat Panel (main_area: Min 3) │
|
|
||||||
│ ┌────────────────────────────────────────┐ │
|
|
||||||
│ │ User: Hello │ │
|
|
||||||
│ │ Agent: Hi there, how can I help? │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Toast notifications (top-right) │ │
|
|
||||||
│ └────────────────────────────────────────┘ │
|
|
||||||
├──────────────────────────────────────────────┤
|
|
||||||
│ Input Bar (3 lines) │
|
|
||||||
│ > Some text... │
|
|
||||||
├──────────────────────────────────────────────┤
|
|
||||||
│ Status Bar (1 line) │
|
|
||||||
│ ┌ Provider │ Model │ Tokens │ Mode │ Quit ─┤
|
|
||||||
└──────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Lines | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `src/view/mod.rs` | 623 | Frame draw, overlays (16 types), input bar, toasts |
|
|
||||||
| `src/view/chat.rs` | 155 | Chat transcript rendering with markdown |
|
|
||||||
| `src/view/markdown.rs` | 144 | Markdown → ratatui `Span` rendering (pulldown-cmark + syntect) |
|
|
||||||
| `src/view/status.rs` | ~50 | Status bar with provider/model/tokens |
|
|
||||||
| `src/view/workflow.rs` | 88 | Workflow progress visualization |
|
|
||||||
| `src/view/theme.rs` | 23 | Color palette (23 named colors) |
|
|
||||||
| `src/controller/input.rs` | 281 | Key event → Action mapping |
|
|
||||||
|
|
||||||
## Overlays (16 types)
|
|
||||||
|
|
||||||
`Overlay::Help | Settings | Bash | QuitConfirm | Workflow | KeyInput | Editor | Effort | Mcp | Todo | Rewind | Learning | Usage | Loading | ModelSelector | ClearConfirm`
|
|
||||||
|
|
||||||
Each overlay renders a centered popup via `render_overlay()`.
|
|
||||||
|
|
||||||
## State Mutations
|
|
||||||
|
|
||||||
State is mutated in-place from two locations:
|
|
||||||
- `src/controller/input.rs` — keyboard shortcuts and overlay interactions
|
|
||||||
- `src/app/runtime/actions/mod.rs` — `apply_action()` reducer for all programmatic actions
|
|
||||||
|
|
||||||
## Toast Notifications
|
|
||||||
|
|
||||||
`render_toasts()` — floating stack at top-right, color-coded by severity:
|
|
||||||
- Info: blue, Success: green, Warning: yellow, Error: red, Lesson: cyan
|
|
||||||
- Max 4 visible, auto-expire after 5s lifetime
|
|
||||||
@@ -119,6 +119,7 @@ fn run_single_process() -> Result<()> {
|
|||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
||||||
|
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
@@ -127,6 +128,7 @@ fn run_single_process() -> Result<()> {
|
|||||||
|
|
||||||
let mut restore_stdout = io::stdout();
|
let mut restore_stdout = io::stdout();
|
||||||
let _ = execute!(restore_stdout, crossterm::event::DisableBracketedPaste);
|
let _ = execute!(restore_stdout, crossterm::event::DisableBracketedPaste);
|
||||||
|
let _ = execute!(restore_stdout, crossterm::event::DisableMouseCapture);
|
||||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
|
|
||||||
@@ -500,6 +502,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
||||||
|
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
@@ -591,6 +594,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
||||||
|
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
||||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
|
|
||||||
@@ -617,6 +621,7 @@ fn run_loop(
|
|||||||
|
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
||||||
|
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
||||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
|
|||||||
+2
-3
@@ -18,7 +18,7 @@
|
|||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Style, Modifier};
|
use ratatui::style::{Color, Style, Modifier};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
|
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
use crate::dto::chat::message::Role;
|
use crate::dto::chat::message::Role;
|
||||||
@@ -253,8 +253,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
|
|
||||||
let paragraph = Paragraph::new(visible)
|
let paragraph = Paragraph::new(visible)
|
||||||
.block(block)
|
.block(block)
|
||||||
.style(Style::default().bg(Theme::BG))
|
.style(Style::default().bg(Theme::BG));
|
||||||
.wrap(Wrap { trim: false });
|
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-18
@@ -30,11 +30,18 @@ use super::theme::Theme;
|
|||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let parser = pulldown_cmark::Parser::new(text);
|
let mut options = pulldown_cmark::Options::empty();
|
||||||
|
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
||||||
|
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
||||||
let mut in_code_block = false;
|
let mut in_code_block = false;
|
||||||
let mut in_heading = false;
|
let mut in_heading = false;
|
||||||
let mut heading_level = 0;
|
let mut heading_level = 0;
|
||||||
|
|
||||||
|
let mut in_table_cell = false;
|
||||||
|
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
|
||||||
|
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
|
||||||
|
let mut current_cell: Vec<Span<'static>> = Vec::new();
|
||||||
|
|
||||||
for event in parser {
|
for event in parser {
|
||||||
match event {
|
match event {
|
||||||
pulldown_cmark::Event::Start(tag) => {
|
pulldown_cmark::Event::Start(tag) => {
|
||||||
@@ -90,6 +97,16 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
Style::default().fg(Theme::BLOCKQUOTE_BAR),
|
Style::default().fg(Theme::BLOCKQUOTE_BAR),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
pulldown_cmark::Tag::Table(_) => {
|
||||||
|
table_rows.clear();
|
||||||
|
}
|
||||||
|
pulldown_cmark::Tag::TableHead | pulldown_cmark::Tag::TableRow => {
|
||||||
|
current_row.clear();
|
||||||
|
}
|
||||||
|
pulldown_cmark::Tag::TableCell => {
|
||||||
|
in_table_cell = true;
|
||||||
|
current_cell.clear();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,14 +131,91 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
|
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
|
pulldown_cmark::TagEnd::TableCell => {
|
||||||
|
in_table_cell = false;
|
||||||
|
current_row.push(std::mem::take(&mut current_cell));
|
||||||
|
}
|
||||||
|
pulldown_cmark::TagEnd::TableHead | pulldown_cmark::TagEnd::TableRow => {
|
||||||
|
table_rows.push(std::mem::take(&mut current_row));
|
||||||
|
}
|
||||||
|
pulldown_cmark::TagEnd::Table => {
|
||||||
|
let cols_count = table_rows.first().map(|r| r.len()).unwrap_or(0);
|
||||||
|
if cols_count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_widths = vec![0; cols_count];
|
||||||
|
for row in &table_rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i < cols_count {
|
||||||
|
let cell_width: usize = cell.iter().map(|s| s.content.chars().count()).sum();
|
||||||
|
if cell_width > col_widths[i] {
|
||||||
|
col_widths[i] = cell_width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective_width = if width > 0 { (width as usize).saturating_sub(2) } else { 0 };
|
||||||
|
let border_overhead = cols_count * 3 + 4;
|
||||||
|
let available_width = effective_width.saturating_sub(border_overhead);
|
||||||
|
let mut total_width: usize = col_widths.iter().sum();
|
||||||
|
|
||||||
|
if width > 0 && total_width > available_width && available_width > 0 {
|
||||||
|
while total_width > available_width {
|
||||||
|
let max_idx = col_widths.iter().enumerate().max_by_key(|&(_, &w)| w).map(|(i, _)| i).unwrap();
|
||||||
|
if col_widths[max_idx] <= 3 { break; }
|
||||||
|
col_widths[max_idx] -= 1;
|
||||||
|
total_width -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.push(Span::raw("\n"));
|
||||||
|
for (r, row) in table_rows.iter().enumerate() {
|
||||||
|
let mut cell_lines = Vec::new();
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i < cols_count {
|
||||||
|
cell_lines.push(wrap_spans_to_lines(cell, col_widths[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
|
||||||
|
|
||||||
|
for y in 0..max_height {
|
||||||
|
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
|
||||||
|
for (i, cl) in cell_lines.iter().enumerate() {
|
||||||
|
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
||||||
|
let mut line_width = 0;
|
||||||
|
for span in line_spans {
|
||||||
|
line_width += span.content.chars().count();
|
||||||
|
spans.push(span.clone());
|
||||||
|
}
|
||||||
|
let pad = col_widths[i].saturating_sub(line_width);
|
||||||
|
spans.push(Span::raw(" ".repeat(pad)));
|
||||||
|
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
|
||||||
|
}
|
||||||
|
spans.push(Span::raw("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == 0 {
|
||||||
|
spans.push(Span::styled(" |", Style::default().fg(Theme::BORDER)));
|
||||||
|
for w in &col_widths {
|
||||||
|
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), Style::default().fg(Theme::BORDER)));
|
||||||
|
}
|
||||||
|
spans.push(Span::raw("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spans.push(Span::raw("\n"));
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pulldown_cmark::Event::Text(text) => {
|
pulldown_cmark::Event::Text(text) => {
|
||||||
let s = text.to_string();
|
let s = text.to_string();
|
||||||
if in_code_block {
|
if in_code_block {
|
||||||
|
let indented = format!(" {}", s.replace('\n', "\n "));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!(" {s}"),
|
indented,
|
||||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
||||||
));
|
));
|
||||||
} else if in_heading {
|
} else if in_heading {
|
||||||
@@ -135,19 +229,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
s,
|
s,
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
|
} else if in_table_cell {
|
||||||
|
current_cell.push(Span::raw(s));
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::raw(s));
|
spans.push(Span::raw(s));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pulldown_cmark::Event::Code(text) => {
|
pulldown_cmark::Event::Code(text) => {
|
||||||
// Inline code with background
|
let span = Span::styled(
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" {text} "),
|
format!(" {text} "),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::ACCENT_TEAL)
|
.fg(Theme::ACCENT_TEAL)
|
||||||
.bg(Theme::CODE_BAR)
|
.bg(Theme::CODE_BAR)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
);
|
||||||
|
if in_table_cell {
|
||||||
|
current_cell.push(span);
|
||||||
|
} else {
|
||||||
|
spans.push(span);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pulldown_cmark::Event::SoftBreak => {
|
pulldown_cmark::Event::SoftBreak => {
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
@@ -164,23 +264,54 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
let mut line_len = 0;
|
let mut line_len = 0;
|
||||||
let effective_width = (width as usize).saturating_sub(2); // leave margin
|
let effective_width = (width as usize).saturating_sub(2); // leave margin
|
||||||
|
|
||||||
for span in &spans {
|
for span in spans {
|
||||||
let style = span.style;
|
let style = span.style;
|
||||||
let s = span.content.clone();
|
let text = span.content.as_ref();
|
||||||
let text_str = s.as_ref();
|
|
||||||
let remaining = text_str.len();
|
|
||||||
|
|
||||||
if line_len + remaining > effective_width && line_len > 0 {
|
let mut current = String::new();
|
||||||
spans_out.push(Span::raw("\n"));
|
let mut tokens = Vec::new();
|
||||||
line_len = 0;
|
for c in text.chars() {
|
||||||
|
if c == ' ' {
|
||||||
|
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
|
||||||
|
tokens.push(" ".to_string());
|
||||||
|
} else if c == '\n' {
|
||||||
|
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
|
||||||
|
tokens.push("\n".to_string());
|
||||||
|
} else {
|
||||||
|
current.push(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !current.is_empty() { tokens.push(current); }
|
||||||
|
|
||||||
spans_out.push(Span::styled(text_str.to_string(), style));
|
for token in tokens {
|
||||||
|
if token == "\n" {
|
||||||
if text_str.contains('\n') {
|
spans_out.push(Span::styled("\n", style));
|
||||||
line_len = text_str.split('\n').next_back().unwrap_or("").len();
|
line_len = 0;
|
||||||
} else {
|
} else if token == " " {
|
||||||
line_len += remaining;
|
if line_len > 0 && line_len < effective_width {
|
||||||
|
spans_out.push(Span::styled(" ", style));
|
||||||
|
line_len += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let token_len = token.chars().count();
|
||||||
|
if line_len + token_len > effective_width && line_len > 0 {
|
||||||
|
spans_out.push(Span::raw("\n"));
|
||||||
|
line_len = 0;
|
||||||
|
}
|
||||||
|
if token_len > effective_width {
|
||||||
|
for c in token.chars() {
|
||||||
|
if line_len >= effective_width {
|
||||||
|
spans_out.push(Span::raw("\n"));
|
||||||
|
line_len = 0;
|
||||||
|
}
|
||||||
|
spans_out.push(Span::styled(c.to_string(), style));
|
||||||
|
line_len += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spans_out.push(Span::styled(token, style));
|
||||||
|
line_len += token_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
spans = spans_out;
|
spans = spans_out;
|
||||||
@@ -188,3 +319,61 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
|
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<Span<'static>>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut current_line = Vec::new();
|
||||||
|
let mut line_len = 0;
|
||||||
|
|
||||||
|
for span in spans {
|
||||||
|
let style = span.style;
|
||||||
|
let text = span.content.as_ref();
|
||||||
|
let mut current_word = String::new();
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
|
||||||
|
for c in text.chars() {
|
||||||
|
if c == ' ' {
|
||||||
|
if !current_word.is_empty() { tokens.push(current_word.clone()); current_word.clear(); }
|
||||||
|
tokens.push(" ".to_string());
|
||||||
|
} else {
|
||||||
|
current_word.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current_word.is_empty() { tokens.push(current_word); }
|
||||||
|
|
||||||
|
for token in tokens {
|
||||||
|
if token == " " {
|
||||||
|
if line_len > 0 && line_len < target_width {
|
||||||
|
current_line.push(Span::styled(" ", style));
|
||||||
|
line_len += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let token_len = token.chars().count();
|
||||||
|
if line_len + token_len > target_width && line_len > 0 {
|
||||||
|
lines.push(std::mem::take(&mut current_line));
|
||||||
|
line_len = 0;
|
||||||
|
}
|
||||||
|
if token_len > target_width {
|
||||||
|
for c in token.chars() {
|
||||||
|
if target_width > 0 && line_len >= target_width {
|
||||||
|
lines.push(std::mem::take(&mut current_line));
|
||||||
|
line_len = 0;
|
||||||
|
}
|
||||||
|
current_line.push(Span::styled(c.to_string(), style));
|
||||||
|
line_len += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current_line.push(Span::styled(token, style));
|
||||||
|
line_len += token_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current_line.is_empty() {
|
||||||
|
lines.push(current_line);
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(vec![]);
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user