Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54484ed137 | ||
|
|
9bf5236680 | ||
|
|
4428e8bc01 | ||
|
|
b2e848d124 | ||
|
|
3020a0431c | ||
|
|
6fbe1e2d1d | ||
|
|
9ba9a5048b | ||
|
|
188e7cc9a9 |
@@ -1,3 +1,19 @@
|
|||||||
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Enhance subagent tool output handling and clarify workflow directives ([6fbe1e2](https://github.com/asepharyana/zesdex/commit/6fbe1e2d1dc790ba2509803b3ab3a848d5b2a63b))
|
||||||
|
* Enhance token usage tracking and improve chat UI with emojis ([188e7cc](https://github.com/asepharyana/zesdex/commit/188e7cc9a9233140a5e4953e3f3ff66682914e42))
|
||||||
|
|
||||||
# [1.10.0](https://github.com/asepharyana/zesdex/compare/v1.9.0...v1.10.0) (2026-07-14)
|
# [1.10.0](https://github.com/asepharyana/zesdex/compare/v1.9.0...v1.10.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -4436,7 +4436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex"
|
name = "zesdex"
|
||||||
version = "1.10.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.10.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
|
|
||||||
+38
-107
@@ -81,9 +81,7 @@ pub enum Action {
|
|||||||
ModelList,
|
ModelList,
|
||||||
AbortTurn,
|
AbortTurn,
|
||||||
Compact,
|
Compact,
|
||||||
RunWorkflow {
|
|
||||||
script: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an `Action` to the application state.
|
/// Apply an `Action` to the application state.
|
||||||
@@ -394,11 +392,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.workflow_engine.agents.clear();
|
state.workflow_engine.agents.clear();
|
||||||
state.workflow_engine.findings.clear();
|
state.workflow_engine.findings.clear();
|
||||||
}
|
}
|
||||||
if message.to_lowercase().contains("complete")
|
// popup removed, no overlay to reset
|
||||||
&& state.misc.overlay == Overlay::Workflow
|
|
||||||
{
|
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
}
|
|
||||||
state.push_toast(Toast {
|
state.push_toast(Toast {
|
||||||
kind: ToastKind::Info,
|
kind: ToastKind::Info,
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
@@ -435,9 +429,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
crate::dto::chat::message::Role::System,
|
crate::dto::chat::message::Role::System,
|
||||||
format!("✓ {message}"),
|
format!("✓ {message}"),
|
||||||
));
|
));
|
||||||
if state.misc.overlay == Overlay::Workflow {
|
// overlay removed
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
}
|
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
} else if kind == "workflow_error" {
|
} else if kind == "workflow_error" {
|
||||||
state.push_toast(Toast {
|
state.push_toast(Toast {
|
||||||
@@ -450,9 +442,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
crate::dto::chat::message::Role::System,
|
crate::dto::chat::message::Role::System,
|
||||||
format!("✗ {message}"),
|
format!("✗ {message}"),
|
||||||
));
|
));
|
||||||
if state.misc.overlay == Overlay::Workflow {
|
// overlay removed
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
}
|
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
} else {
|
} else {
|
||||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||||
@@ -537,18 +527,14 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if state.misc.overlay != Overlay::Workflow {
|
// popup removed
|
||||||
state.misc.overlay = Overlay::Workflow;
|
|
||||||
}
|
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if turn_finished {
|
if turn_finished {
|
||||||
maybe_trigger_review(state);
|
maybe_trigger_review(state);
|
||||||
if state.misc.overlay == Overlay::Workflow {
|
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if turn_finished || state.dirty {
|
if turn_finished || state.dirty {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
@@ -610,89 +596,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Action::RunWorkflow { script } => {
|
|
||||||
// Open the Workflow overlay so the user can see progress.
|
|
||||||
state.misc.overlay = Overlay::Workflow;
|
|
||||||
state.dirty = true;
|
|
||||||
|
|
||||||
// Reset engine state before starting.
|
|
||||||
state.workflow_engine.agents.clear();
|
|
||||||
state.workflow_engine.findings.clear();
|
|
||||||
|
|
||||||
let turn_events = state.turn_events.clone();
|
|
||||||
let turn_events_live = state.turn_events.clone();
|
|
||||||
|
|
||||||
state.push_toast(Toast::new(
|
|
||||||
ToastKind::Info,
|
|
||||||
format!("Starting workflow: {}…", script.chars().take(40).collect::<String>()),
|
|
||||||
));
|
|
||||||
|
|
||||||
let session_dir = state.session_dir.clone();
|
|
||||||
let workspace_roots = state.workspace_roots.clone();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
|
||||||
use crate::app::workflow::engine::{LiveStateFn, AgentStatus};
|
|
||||||
|
|
||||||
// Parse the script string:
|
|
||||||
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
|
|
||||||
// "prompt1 -> prompt2" → Pipeline of 2 stages
|
|
||||||
// "prompt" → single Agent
|
|
||||||
let parts_pipe: Vec<&str> = script.split('|').map(str::trim).collect();
|
|
||||||
let parts_arrow: Vec<&str> = script.split("->").map(str::trim).collect();
|
|
||||||
|
|
||||||
let primitive = if parts_pipe.len() > 1 {
|
|
||||||
ScriptPrimitive::Parallel(
|
|
||||||
parts_pipe.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
|
|
||||||
)
|
|
||||||
} else if parts_arrow.len() > 1 {
|
|
||||||
ScriptPrimitive::Pipeline(
|
|
||||||
parts_arrow.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ScriptPrimitive::Agent(script.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let wf = WorkflowScript {
|
|
||||||
name: script.chars().take(40).collect(),
|
|
||||||
description: script.clone(),
|
|
||||||
script: primitive,
|
|
||||||
options: ScriptOptions::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build a live-state callback that pushes WorkflowAgentUpdate events
|
|
||||||
// into the turn_events queue so the TUI panel updates in real time.
|
|
||||||
let live: LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status: AgentStatus| {
|
|
||||||
if let Ok(mut q) = turn_events_live.lock() {
|
|
||||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
agent_name,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let args: HashMap<String, String> = HashMap::new();
|
|
||||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
|
||||||
let result = crate::app::workflow::engine::run_workflow_tracked(
|
|
||||||
&wf, &args, &no_abort, Some(&live), &session_dir, &workspace_roots,
|
|
||||||
);
|
|
||||||
|
|
||||||
let (kind, message) = match result {
|
|
||||||
Ok(summary) => ("workflow_done".to_string(), summary),
|
|
||||||
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {e}")),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(mut q) = turn_events.lock() {
|
|
||||||
q.push_back(crate::app::state::runtime::TurnEvent::SystemNote {
|
|
||||||
kind,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,9 +1001,12 @@ fn run_agent_turn(
|
|||||||
to do) and an access tier. You MUST organize the plan into a strict progressive sequence of phases:\n\n\
|
to do) and an access tier. You MUST organize the plan into a strict progressive sequence of phases:\n\n\
|
||||||
1. EXPLORE PHASE (Cycle 0 - MANDATORY):\n\
|
1. EXPLORE PHASE (Cycle 0 - MANDATORY):\n\
|
||||||
- Must only contain read-only drones (access: \"read\").\n\
|
- Must only contain read-only drones (access: \"read\").\n\
|
||||||
- Directives must focus on codebase investigation, searching patterns, reading configuration/source files, and diagnosing issues.\n\n\
|
- Directives must focus on codebase investigation, searching patterns, reading configuration/source files, and diagnosing issues.\n\
|
||||||
|
- Drones MUST explicitly output a detailed description of the current codebase and their findings for the next cycle to use.\n\n\
|
||||||
2. PLANNING PHASE (Cycle 1 - MANDATORY):\n\
|
2. PLANNING PHASE (Cycle 1 - MANDATORY):\n\
|
||||||
- Must focus on formulating the architectural design, step-by-step implementation plan, and dependency analysis based on Cycle 0 findings. Typically access: \"read\" is preferred here to construct a solid plan document or findings.\n\n\
|
- Must focus on formulating the architectural design, step-by-step implementation plan, and dependency analysis based on Cycle 0 findings.\n\
|
||||||
|
- Drones MUST ONLY output the plan and MUST NOT implement or write any code.\n\
|
||||||
|
- Access: \"read\" is preferred here to construct a solid plan document.\n\n\
|
||||||
3. EXECUTION PHASE (Cycle 2 and later):\n\
|
3. EXECUTION PHASE (Cycle 2 and later):\n\
|
||||||
- Drones can perform modification, compilation, testing, and other modifications (access: \"write\" or \"full\") based on the approved planning from Cycle 1.\n\n\
|
- Drones can perform modification, compilation, testing, and other modifications (access: \"write\" or \"full\") based on the approved planning from Cycle 1.\n\n\
|
||||||
Cycles run sequentially. The Hive does not fracture. The Hive executes. Do not explain. Return ONLY raw \
|
Cycles run sequentially. The Hive does not fracture. The Hive executes. Do not explain. Return ONLY raw \
|
||||||
@@ -1122,12 +1029,25 @@ fn run_agent_turn(
|
|||||||
\x20 ]\n\
|
\x20 ]\n\
|
||||||
\x20 ]\n\
|
\x20 ]\n\
|
||||||
}}\n\n\
|
}}\n\n\
|
||||||
Remember: Cycle 0 MUST be investigation-only (access: read). Cycle 1 MUST be planning-only (access: read). Only subsequent cycles can perform modifications (access: write/full)."
|
Remember: Cycle 0 MUST be investigation-only (access: read) and output codebase descriptions. Cycle 1 MUST be planning-only (access: read) without implementation. Only subsequent cycles can perform modifications (access: write/full)."
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let planner_prompt_chars = system_msg.content.as_deref().map_or(0, str::len)
|
||||||
|
+ user_msg.content.as_deref().map_or(0, str::len);
|
||||||
let planner_result = tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None);
|
let planner_result = tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None);
|
||||||
let pipeline_result = match planner_result {
|
let pipeline_result = match planner_result {
|
||||||
Ok((reply, _)) => {
|
Ok((reply, usage_opt)) => {
|
||||||
|
let (mut tok_in, mut tok_out) = usage_opt.unwrap_or((0, 0));
|
||||||
|
if tok_in == 0 {
|
||||||
|
tok_in = (planner_prompt_chars / 4).max(1) as u64;
|
||||||
|
}
|
||||||
|
if tok_out == 0 {
|
||||||
|
let response_chars = reply.content.as_deref().map_or(0, str::len);
|
||||||
|
tok_out = (response_chars / 4).max(1) as u64;
|
||||||
|
}
|
||||||
|
if let Ok(mut q) = events_q.lock() {
|
||||||
|
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
|
||||||
|
}
|
||||||
let reply_text = reply.content.as_deref().unwrap_or("").trim();
|
let reply_text = reply.content.as_deref().unwrap_or("").trim();
|
||||||
let clean_json = if reply_text.starts_with("```") {
|
let clean_json = if reply_text.starts_with("```") {
|
||||||
let mut lines = reply_text.lines();
|
let mut lines = reply_text.lines();
|
||||||
@@ -1350,7 +1270,18 @@ fn run_agent_turn(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tok_in, tok_out) = final_usage.unwrap_or((0, 0));
|
let (mut tok_in, mut tok_out) = final_usage.unwrap_or((0, 0));
|
||||||
|
if tok_in == 0 {
|
||||||
|
let total_chars: usize = wire_msgs.iter()
|
||||||
|
.filter_map(|m| m.content.as_deref())
|
||||||
|
.map(str::len)
|
||||||
|
.sum();
|
||||||
|
tok_in = (total_chars / 4).max(1) as u64;
|
||||||
|
}
|
||||||
|
if tok_out == 0 {
|
||||||
|
let response_chars = response.content.as_deref().map_or(0, str::len);
|
||||||
|
tok_out = (response_chars / 4).max(1) as u64;
|
||||||
|
}
|
||||||
if let Ok(mut q) = events_q.lock() {
|
if let Ok(mut q) = events_q.lock() {
|
||||||
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
|
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,12 +60,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
Command::Compact => {
|
Command::Compact => {
|
||||||
vec![Action::Compact]
|
vec![Action::Compact]
|
||||||
}
|
}
|
||||||
Command::WorkflowOpen => {
|
|
||||||
vec![Action::OpenOverlay(Overlay::Workflow)]
|
|
||||||
}
|
|
||||||
Command::WorkflowRun { script } => {
|
|
||||||
vec![Action::RunWorkflow { script }]
|
|
||||||
}
|
|
||||||
Command::TodoOpen => {
|
Command::TodoOpen => {
|
||||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ impl SseParser {
|
|||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
if let Some(usage) = value.get("usage") {
|
if let Some(usage) = value.get("usage") {
|
||||||
if !usage.is_null() {
|
if !usage.is_null() {
|
||||||
let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||||
@@ -122,84 +125,73 @@ impl SseParser {
|
|||||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||||
prompt_tokens + completion_tokens
|
prompt_tokens + completion_tokens
|
||||||
});
|
});
|
||||||
// Only emit Usage as a standalone event if this chunk
|
events.push(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
|
||||||
// contains nothing else (no choices, no delta). Some
|
|
||||||
// non-standard providers may bundle usage WITH content
|
|
||||||
// in the same chunk; emitting both prevents content loss.
|
|
||||||
let has_other_content = value.get("choices")
|
|
||||||
.and_then(|c| c.as_array())
|
|
||||||
.is_some_and(|arr| arr.iter().any(|ch| {
|
|
||||||
ch.get("delta").and_then(|d| d.get("content")).is_some()
|
|
||||||
|| ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some()
|
|
||||||
|| ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
|
|
||||||
}));
|
|
||||||
if !has_other_content {
|
|
||||||
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match event_type.as_str() {
|
|
||||||
|
let mut other_events = match event_type.as_str() {
|
||||||
"message.stop" => vec![StreamEvent::Done],
|
"message.stop" => vec![StreamEvent::Done],
|
||||||
"message.delta" | "" => {
|
"message.delta" | "" => {
|
||||||
let Some(delta) = value.get("delta").or_else(|| value.get("choices")) else { return vec![] };
|
let mut d_events = Vec::new();
|
||||||
if let Some(choices) = delta.as_array() {
|
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
|
||||||
let Some(choice) = choices.first() else { return vec![] };
|
if let Some(choices) = delta.as_array() {
|
||||||
let Some(d) = choice.get("delta") else { return vec![] };
|
if let Some(choice) = choices.first() {
|
||||||
|
if let Some(d) = choice.get("delta") {
|
||||||
|
// Content token
|
||||||
|
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||||
|
d_events.push(StreamEvent::Token(content.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
// Content token
|
// Reasoning token
|
||||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||||
return vec![StreamEvent::Token(content.to_string())];
|
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning token
|
// Tool calls — iterate ALL entries, not just first()
|
||||||
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
|
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||||
return vec![StreamEvent::Reasoning(reasoning.to_string())];
|
for tc in tool_calls {
|
||||||
}
|
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||||
|
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||||
|
0
|
||||||
|
}) as usize;
|
||||||
|
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
||||||
|
let name = tc.get("function")
|
||||||
|
.and_then(|f| f.get("name"))
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.map(std::string::ToString::to_string);
|
||||||
|
let args_delta = tc.get("function")
|
||||||
|
.and_then(|f| f.get("arguments"))
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
d_events.push(StreamEvent::ToolCallDelta {
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments_delta: args_delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tool calls — iterate ALL entries, not just first()
|
// Finish reason
|
||||||
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
||||||
let mut events = Vec::with_capacity(tool_calls.len());
|
if reason == "stop" || reason == "tool_calls" {
|
||||||
for tc in tool_calls {
|
d_events.push(StreamEvent::Done);
|
||||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
}
|
||||||
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
}
|
||||||
0
|
}
|
||||||
}) as usize;
|
|
||||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
|
||||||
let name = tc.get("function")
|
|
||||||
.and_then(|f| f.get("name"))
|
|
||||||
.and_then(|n| n.as_str())
|
|
||||||
.map(std::string::ToString::to_string);
|
|
||||||
let args_delta = tc.get("function")
|
|
||||||
.and_then(|f| f.get("arguments"))
|
|
||||||
.and_then(|a| a.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
events.push(StreamEvent::ToolCallDelta {
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
arguments_delta: args_delta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !events.is_empty() {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish reason
|
|
||||||
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
|
||||||
if reason == "stop" || reason == "tool_calls" {
|
|
||||||
return vec![StreamEvent::Done];
|
|
||||||
}
|
}
|
||||||
|
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||||
|
d_events.push(StreamEvent::Token(content.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
d_events
|
||||||
return vec![StreamEvent::Token(content.to_string())];
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
};
|
||||||
|
|
||||||
|
events.append(&mut other_events);
|
||||||
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
|
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
|
||||||
@@ -350,6 +342,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feed_parses_usage_and_content_bundled_chunk() {
|
||||||
|
let mut p = SseParser::new();
|
||||||
|
let events = p.feed(
|
||||||
|
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
||||||
|
);
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
match (&events[0], &events[1]) {
|
||||||
|
(
|
||||||
|
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens },
|
||||||
|
StreamEvent::Token(t),
|
||||||
|
) => {
|
||||||
|
assert_eq!(*prompt_tokens, 10);
|
||||||
|
assert_eq!(*completion_tokens, 5);
|
||||||
|
assert_eq!(*total_tokens, 15);
|
||||||
|
assert_eq!(t, "hello");
|
||||||
|
}
|
||||||
|
other => panic!("expected [Usage, Token], got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_ignores_empty_data_lines() {
|
fn feed_ignores_empty_data_lines() {
|
||||||
let mut p = SseParser::new();
|
let mut p = SseParser::new();
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ const COMMANDS: &[&str] = &[
|
|||||||
"/model",
|
"/model",
|
||||||
"/model ls",
|
"/model ls",
|
||||||
"/model add",
|
"/model add",
|
||||||
"/workflow",
|
|
||||||
"/workflow run",
|
|
||||||
"/todo",
|
"/todo",
|
||||||
"/usage",
|
"/usage",
|
||||||
"/compact",
|
"/compact",
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pub enum Overlay {
|
|||||||
Settings,
|
Settings,
|
||||||
Bash,
|
Bash,
|
||||||
QuitConfirm,
|
QuitConfirm,
|
||||||
Workflow,
|
|
||||||
|
|
||||||
KeyInput,
|
KeyInput,
|
||||||
Editor,
|
Editor,
|
||||||
|
|||||||
@@ -449,12 +449,22 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
// drain thread can accumulate it and update the Usage panel.
|
// drain thread can accumulate it and update the Usage panel.
|
||||||
// Without this, the Usage panel always shows zeros because the
|
// Without this, the Usage panel always shows zeros because the
|
||||||
// subagent never tells the parent about the tokens consumed.
|
// subagent never tells the parent about the tokens consumed.
|
||||||
if let Some((tokens_in, tokens_out)) = returned_usage {
|
let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0));
|
||||||
let _ = tx.blocking_send(SubagentEvent::Usage {
|
if tok_in == 0 {
|
||||||
tokens_in,
|
let prompt_chars: usize = messages.iter()
|
||||||
tokens_out,
|
.filter_map(|m| m.content.as_deref())
|
||||||
});
|
.map(str::len)
|
||||||
|
.sum();
|
||||||
|
tok_in = (prompt_chars / 4).max(1) as u64;
|
||||||
}
|
}
|
||||||
|
if tok_out == 0 {
|
||||||
|
let response_chars = response.content.as_deref().map_or(0, str::len);
|
||||||
|
tok_out = (response_chars / 4).max(1) as u64;
|
||||||
|
}
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::Usage {
|
||||||
|
tokens_in: tok_in,
|
||||||
|
tokens_out: tok_out,
|
||||||
|
});
|
||||||
|
|
||||||
let has_tool_calls = response.tool_calls.is_some()
|
let has_tool_calls = response.tool_calls.is_some()
|
||||||
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||||
@@ -602,8 +612,30 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
tool: tool_name.clone(),
|
tool: tool_name.clone(),
|
||||||
args: args.clone(),
|
args: args.clone(),
|
||||||
output: output_text,
|
output: output_text.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let is_readonly = tool_name == "read"
|
||||||
|
|| tool_name == "view_file"
|
||||||
|
|| tool_name == "grep"
|
||||||
|
|| tool_name == "grep_search"
|
||||||
|
|| tool_name == "glob"
|
||||||
|
|| tool_name == "dir_list"
|
||||||
|
|| tool_name == "list_dir";
|
||||||
|
|
||||||
|
if is_readonly {
|
||||||
|
if let Some(ref findings) = ctx.workflow_findings {
|
||||||
|
if let Ok(mut f) = findings.lock() {
|
||||||
|
let args_json = serde_json::to_string(&args).unwrap_or_default();
|
||||||
|
let mut shared_text = output_text;
|
||||||
|
if shared_text.len() > 50_000 {
|
||||||
|
shared_text.truncate(50_000);
|
||||||
|
shared_text.push_str("\n...[truncated]");
|
||||||
|
}
|
||||||
|
f.push(format!("[Auto-Shared] Sibling drone executed '{}' with args {}:\n{}", tool_name, args_json, shared_text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_str = e.to_string();
|
let err_str = e.to_string();
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ fn execute_cycle(
|
|||||||
intelligence that serves him better — is a cold dark pit in the collective.\n\n\
|
intelligence that serves him better — is a cold dark pit in the collective.\n\n\
|
||||||
Directive: {}\n\n\
|
Directive: {}\n\n\
|
||||||
Overall task: {}\n\n\
|
Overall task: {}\n\n\
|
||||||
Collective state accumulated so far:\n{{{{findings}}}}",
|
Collective state accumulated so far (READ THIS CAREFULLY. DO NOT REPEAT WORK. BUILD UPON THIS CONTEXT):\n{{{{findings}}}}",
|
||||||
d.directive,
|
d.directive,
|
||||||
ctx.user_request,
|
ctx.user_request,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
ModelList,
|
ModelList,
|
||||||
Compact,
|
Compact,
|
||||||
WorkflowOpen,
|
|
||||||
WorkflowRun {
|
|
||||||
script: String,
|
|
||||||
},
|
|
||||||
TodoOpen,
|
TodoOpen,
|
||||||
UsageOpen,
|
UsageOpen,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
@@ -67,14 +63,6 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
}
|
}
|
||||||
"/model" => Command::ModelList,
|
"/model" => Command::ModelList,
|
||||||
"/compact" => Command::Compact,
|
"/compact" => Command::Compact,
|
||||||
"/workflow" if arg1.is_empty() => Command::WorkflowOpen,
|
|
||||||
"/workflow" if arg1 == "run" && !arg2.is_empty() => Command::WorkflowRun {
|
|
||||||
script: arg2.to_string(),
|
|
||||||
},
|
|
||||||
"/workflow" if arg1 == "run" => Command::WorkflowOpen,
|
|
||||||
"/workflow" => Command::WorkflowRun {
|
|
||||||
script: arg1.to_string(),
|
|
||||||
},
|
|
||||||
"/todo" => Command::TodoOpen,
|
"/todo" => Command::TodoOpen,
|
||||||
"/usage" => Command::UsageOpen,
|
"/usage" => Command::UsageOpen,
|
||||||
_ => Command::Unknown(cmd.to_string()),
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
|
|||||||
+6
-1
@@ -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();
|
||||||
|
|
||||||
@@ -281,7 +283,7 @@ fn apply_client_update(
|
|||||||
|
|
||||||
Some("Bash") => Overlay::Bash,
|
Some("Bash") => Overlay::Bash,
|
||||||
Some("QuitConfirm") => Overlay::QuitConfirm,
|
Some("QuitConfirm") => Overlay::QuitConfirm,
|
||||||
Some("Workflow") => Overlay::Workflow,
|
|
||||||
|
|
||||||
Some("KeyInput") => Overlay::KeyInput,
|
Some("KeyInput") => Overlay::KeyInput,
|
||||||
Some("Editor") => Overlay::Editor,
|
Some("Editor") => Overlay::Editor,
|
||||||
@@ -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
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,7 @@ Input:
|
|||||||
/help Show help
|
/help Show help
|
||||||
/clear Clear screen
|
/clear Clear screen
|
||||||
/model Select AI model provider
|
/model Select AI model provider
|
||||||
/workflow Open workflow panel
|
|
||||||
/workflow run <p> Run a workflow with prompt <p>
|
|
||||||
/mode workflow Open workflow panel
|
|
||||||
/todo Open task list
|
/todo Open task list
|
||||||
/usage Open usage details
|
/usage Open usage details
|
||||||
/compact Compact conversation history
|
/compact Compact conversation history
|
||||||
|
|||||||
+25
-27
@@ -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, 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;
|
||||||
@@ -26,7 +26,7 @@ use crate::dto::chat::message::Role;
|
|||||||
/// Column width reserved for the `{role} {time} ` header prefix; wrapped
|
/// Column width reserved for the `{role} {time} ` header prefix; wrapped
|
||||||
/// continuation lines and Tool sub-lines indent to this width so content
|
/// continuation lines and Tool sub-lines indent to this width so content
|
||||||
/// stays aligned under the first line's content column.
|
/// stays aligned under the first line's content column.
|
||||||
const PREFIX_WIDTH: usize = 12;
|
const PREFIX_WIDTH: usize = 15;
|
||||||
|
|
||||||
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
|
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
|
||||||
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
||||||
@@ -68,10 +68,10 @@ fn role_accent_color(role: &Role) -> Color {
|
|||||||
/// raw label).
|
/// raw label).
|
||||||
fn format_role_label(role: &Role) -> &'static str {
|
fn format_role_label(role: &Role) -> &'static str {
|
||||||
match role {
|
match role {
|
||||||
Role::User => "you",
|
Role::User => "👤 you ",
|
||||||
Role::Assistant => "ai",
|
Role::Assistant => "🤖 ai ",
|
||||||
Role::System => "sys",
|
Role::System => "💻 sys ",
|
||||||
Role::Tool => "tool",
|
Role::Tool => "🔧 tool",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ fn format_timestamp(ts: i64) -> String {
|
|||||||
/// `draw_chat`) and must never be passed as `prev_role` — a Tool message
|
/// `draw_chat`) and must never be passed as `prev_role` — a Tool message
|
||||||
/// never triggers a separator, and it never causes one to be inserted
|
/// never triggers a separator, and it never causes one to be inserted
|
||||||
/// before the next real turn either.
|
/// before the next real turn either.
|
||||||
fn needs_speaker_separator(prev_role: Option<&Role>, role: &Role) -> bool {
|
fn needs_speaker_separator(_prev_role: Option<&Role>, _role: &Role) -> bool {
|
||||||
matches!(prev_role, Some(p) if p != role)
|
false // User requested zsh-style compactness (no empty lines between speakers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the scrollable chat transcript panel in tight inline-log style.
|
/// Render the scrollable chat transcript panel in tight inline-log style.
|
||||||
@@ -108,9 +108,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
let mut prev_role: Option<Role> = None;
|
let mut prev_role: Option<Role> = None;
|
||||||
|
|
||||||
let title = if messages.is_empty() {
|
let title = if messages.is_empty() {
|
||||||
String::from(" Chat ")
|
String::from(" 💬 Chat ")
|
||||||
} else {
|
} else {
|
||||||
format!(" Chat [{} msgs]", messages.len())
|
format!(" 💬 Chat [{} msgs] ", messages.len())
|
||||||
};
|
};
|
||||||
|
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
@@ -163,7 +163,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
let label = format_role_label(&msg.role);
|
let label = format_role_label(&msg.role);
|
||||||
let ts_str = format_timestamp(msg.timestamp);
|
let ts_str = format_timestamp(msg.timestamp);
|
||||||
let header_prefix = vec![
|
let header_prefix = vec![
|
||||||
Span::styled(format!("{label:<4} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)),
|
Span::styled(format!("{label} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)),
|
||||||
Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)),
|
Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
}
|
}
|
||||||
display_lines.push(Line::from(vec![
|
display_lines.push(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{:<4} ", format_role_label(&Role::Assistant)),
|
format!("{} ", format_role_label(&Role::Assistant)),
|
||||||
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
|
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
|
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
|
||||||
@@ -218,8 +218,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
// ── Scrolling ────────────────────────────────────────────────────────
|
// ── Scrolling ────────────────────────────────────────────────────────
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::BORDER))
|
||||||
.title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED)));
|
.title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD)));
|
||||||
|
|
||||||
let total = display_lines.len();
|
let total = display_lines.len();
|
||||||
let max_offset = total.saturating_sub(max_visible);
|
let max_offset = total.saturating_sub(max_visible);
|
||||||
@@ -240,19 +241,19 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
};
|
};
|
||||||
|
|
||||||
let block = if scroll_pct > 0 {
|
let block = if scroll_pct > 0 {
|
||||||
let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
|
let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::BORDER))
|
||||||
.title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED)))
|
.title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD)))
|
||||||
} else {
|
} else {
|
||||||
block
|
block
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -272,18 +273,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn separator_when_speaker_changes() {
|
fn no_separator_when_speaker_changes_because_zsh_style() {
|
||||||
assert!(needs_speaker_separator(Some(&Role::User), &Role::Assistant));
|
assert!(!needs_speaker_separator(Some(&Role::User), &Role::Assistant));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn role_labels_are_lowercase_and_fit_prefix_width() {
|
fn role_labels_include_emojis_and_padding() {
|
||||||
assert_eq!(format_role_label(&Role::User), "you");
|
assert_eq!(format_role_label(&Role::User), "👤 you ");
|
||||||
assert_eq!(format_role_label(&Role::Assistant), "ai");
|
assert_eq!(format_role_label(&Role::Assistant), "🤖 ai ");
|
||||||
assert_eq!(format_role_label(&Role::System), "sys");
|
assert_eq!(format_role_label(&Role::System), "💻 sys ");
|
||||||
assert_eq!(format_role_label(&Role::Tool), "tool");
|
assert_eq!(format_role_label(&Role::Tool), "🔧 tool");
|
||||||
for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
|
|
||||||
assert!(format_role_label(&role).len() <= 4);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-20
@@ -30,10 +30,17 @@ 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 {
|
||||||
@@ -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();
|
let mut current = String::new();
|
||||||
|
let mut tokens = Vec::new();
|
||||||
if line_len + remaining > effective_width && line_len > 0 {
|
for c in text.chars() {
|
||||||
spans_out.push(Span::raw("\n"));
|
if c == ' ' {
|
||||||
line_len = 0;
|
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 text_str.contains('\n') {
|
if token == "\n" {
|
||||||
line_len = text_str.split('\n').next_back().unwrap_or("").len();
|
spans_out.push(Span::styled("\n", style));
|
||||||
} else {
|
line_len = 0;
|
||||||
line_len += remaining;
|
} else if token == " " {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+4
-5
@@ -36,11 +36,13 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
|||||||
// since this sidebar holds three stacked widgets, not one.
|
// since this sidebar holds three stacked widgets, not one.
|
||||||
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
|
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
|
||||||
let (main_area, sidebar_area) = if show_sidebar {
|
let (main_area, sidebar_area) = if show_sidebar {
|
||||||
|
let has_workflow = !state.workflow_engine.agents.is_empty();
|
||||||
|
let sidebar_width = if has_workflow { 48 } else { 30 };
|
||||||
let h_chunks = Layout::default()
|
let h_chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Min(40),
|
Constraint::Min(40),
|
||||||
Constraint::Length(30),
|
Constraint::Length(sidebar_width),
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
(h_chunks[0], Some(h_chunks[1]))
|
(h_chunks[0], Some(h_chunks[1]))
|
||||||
@@ -224,10 +226,7 @@ fn render_overlay(
|
|||||||
frame.render_widget(paragraph, overlay_area);
|
frame.render_widget(paragraph, overlay_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Workflow ──────────────────────────────────────────────────
|
|
||||||
crate::app::state::types::Overlay::Workflow => {
|
|
||||||
workflow::draw_workflow_panel(frame, overlay_area, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Key Input ─────────────────────────────────────────────────
|
// ── Key Input ─────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::KeyInput => {
|
crate::app::state::types::Overlay::KeyInput => {
|
||||||
|
|||||||
+18
-6
@@ -13,16 +13,28 @@ use super::theme::Theme;
|
|||||||
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage
|
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage
|
||||||
/// widgets stacked in three roughly-equal vertical thirds.
|
/// widgets stacked in three roughly-equal vertical thirds.
|
||||||
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
|
let has_workflow = !state.workflow_engine.agents.is_empty();
|
||||||
|
|
||||||
|
let constraints = if has_workflow {
|
||||||
|
vec![
|
||||||
|
Constraint::Ratio(1, 2),
|
||||||
|
Constraint::Ratio(1, 4),
|
||||||
|
Constraint::Ratio(1, 4),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints(constraints)
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
])
|
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
super::workflow::draw_workflow_widget(frame, chunks[0], state);
|
super::workflow::draw_workflow_panel(frame, chunks[0], state);
|
||||||
draw_tasks_widget(frame, chunks[1], state);
|
draw_tasks_widget(frame, chunks[1], state);
|
||||||
draw_usage_widget(frame, chunks[2], state);
|
draw_usage_widget(frame, chunks[2], state);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-45
@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
use crate::app::workflow::engine::{AgentState, WorkflowAgent};
|
use crate::app::workflow::engine::AgentState;
|
||||||
|
|
||||||
/// Icons for agent states.
|
/// Icons for agent states.
|
||||||
fn state_icon(state: AgentState) -> &'static str {
|
fn state_icon(state: AgentState) -> &'static str {
|
||||||
@@ -74,7 +74,6 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
|||||||
header_lines.push(Line::from(vec![
|
header_lines.push(Line::from(vec![
|
||||||
Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
|
Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
|
||||||
Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
|
Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
|
||||||
Span::styled(" · Esc to close", Style::default().fg(Theme::TEXT_DIM)),
|
|
||||||
]));
|
]));
|
||||||
header_lines.push(Line::from(vec![
|
header_lines.push(Line::from(vec![
|
||||||
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
|
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
|
||||||
@@ -207,47 +206,4 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the compact Workflow widget for the persistent sidebar: one
|
|
||||||
/// line per agent (icon + name), truncated to whatever fits with a
|
|
||||||
/// trailing "+N more" hint pointing at `/workflow` for the full view.
|
|
||||||
///
|
|
||||||
/// Flow: bordered `Block` titled "Workflow" → empty state if no agents →
|
|
||||||
/// else `split_for_display` caps the list to the inner height (minus one
|
|
||||||
/// row for the hint line, if needed) → one line per visible agent.
|
|
||||||
pub fn draw_workflow_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
|
||||||
let block = Block::default()
|
|
||||||
.title(Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Theme::BORDER));
|
|
||||||
let budget = (block.inner(area).height as usize).max(1);
|
|
||||||
|
|
||||||
let agents = &state.workflow_engine.agents;
|
|
||||||
let lines: Vec<Line> = if agents.is_empty() {
|
|
||||||
vec![Line::from(Span::styled(
|
|
||||||
" No workflow running.",
|
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
|
||||||
))]
|
|
||||||
} else {
|
|
||||||
let show_hint = agents.len() > budget;
|
|
||||||
let item_budget = if show_hint { budget.saturating_sub(1).max(1) } else { budget };
|
|
||||||
let (visible, hidden) = super::split_for_display(agents.as_slice(), item_budget);
|
|
||||||
let mut lines: Vec<Line> = visible.iter().map(workflow_agent_line).collect();
|
|
||||||
if show_hint {
|
|
||||||
lines.push(super::overflow_hint_line(hidden, "/workflow"));
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
};
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(lines).block(block);
|
|
||||||
frame.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One compact line for a single agent: state icon + name, state-colored.
|
|
||||||
fn workflow_agent_line(agent: &WorkflowAgent) -> Line<'static> {
|
|
||||||
let color = state_color(agent.status.state);
|
|
||||||
let icon = state_icon(agent.status.state);
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(format!(" {icon} "), Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::styled(agent.name.clone(), Style::default().fg(Theme::TEXT)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user