Compare commits

...
17 Commits
Author SHA1 Message Date
semantic-release-bot 4a297669b4 chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/asepharyana/zesdex/compare/v1.1.0...v1.2.0) (2026-07-13)

### Features

* enhance responsiveness by implementing abort checks in streaming API calls ([0d6f558](https://github.com/asepharyana/zesdex/commit/0d6f558b2bd0282a7a1695f7680ab1d1c6142579))
* refactor agent step limits and enhance workflow orchestration with new findings tool ([3b660e0](https://github.com/asepharyana/zesdex/commit/3b660e09a87f3e982db94f48d2282ddb63116341))
* remove pipeline command and refactor workflow execution to use custom specialists ([00e2913](https://github.com/asepharyana/zesdex/commit/00e29139c53c5fed4c13b0493297dd9da984460c))
* update overlay handling in apply_action and remove mouse capture from terminal execution ([2e351cc](https://github.com/asepharyana/zesdex/commit/2e351ccf6930ff4823f55b581308222229fe6684))
* update README and documentation for new tools and features ([1d50b94](https://github.com/asepharyana/zesdex/commit/1d50b94eec1ed82dfc40d43d41bd01aeb79edfe1))
2026-07-13 07:43:49 +00:00
asepharyana 2e351ccf69 feat: update overlay handling in apply_action and remove mouse capture from terminal execution 2026-07-13 14:39:39 +07:00
asepharyana 1d50b94eec feat: update README and documentation for new tools and features
- Updated README.md to reflect the addition of 3 new built-in tools, bringing the total to 37.
- Revised architecture documentation to indicate the increase in tool count.
- Enhanced backend documentation with updated line counts for various modules.
- Modified data documentation to change edit log format from JSON to JSONL.
- Updated dependencies documentation to reflect version upgrades for several crates.
- Improved prompts for auto-reviewer, division implementer, planner, tester, and quality reviewer to enforce stricter coding standards regarding linter bypasses.
- Refactored code in various modules to improve clarity and performance, including updates to error handling and tool execution logic.
- Added comprehensive tests for IPC frame serialization and deserialization.
2026-07-13 14:39:39 +07:00
asepharyana 00e29139c5 feat: remove pipeline command and refactor workflow execution to use custom specialists 2026-07-13 14:39:39 +07:00
asepharyana 3b660e09a8 feat: refactor agent step limits and enhance workflow orchestration with new findings tool 2026-07-13 14:39:39 +07:00
asepharyana 0d6f558b2b feat: enhance responsiveness by implementing abort checks in streaming API calls 2026-07-13 14:39:39 +07:00
semantic-release-bot 8388a83af0 chore(release): 1.1.0 [skip ci]
# [1.1.0](https://github.com/asepharyana/zesdex/compare/v1.0.4...v1.1.0) (2026-07-13)

### Features

* implement abort mechanism for workflows and subagents ([104b0da](https://github.com/asepharyana/zesdex/commit/104b0daf4cc51581de04f03d6b727cdb16f9b6c3))
2026-07-13 02:13:34 +00:00
asepharyana 104b0daf4c feat: implement abort mechanism for workflows and subagents 2026-07-13 09:09:23 +07:00
asepharyana 65647ce517 Update dependencies and refactor SHA256 hash encoding
- Updated `crossterm` from version 0.28 to 0.29.
- Upgraded `reqwest` from version 0.12 to 0.13 and added "form" feature.
- Bumped `serde_yaml_ng` from version 0.9 to 0.10.
- Increased `dirs` version from 5 to 6.
- Updated `rusqlite` from version 0.32 to 0.40.
- Upgraded `infer` from version 0.16 to 0.19.
- Bumped `sha2` from version 0.10 to 0.11.
- Updated `rmcp` from version 1.8 to 2.2.
- Refactored SHA256 hash encoding in `rewind.rs`, `mod.rs`, and `rest.rs` to use `hex::encode` instead of formatting with `{:x}` for better clarity and consistency.
2026-07-13 08:39:14 +07:00
semantic-release-bot 152b245f5e chore(release): 1.0.4 [skip ci]
## [1.0.4](https://github.com/asepharyana/zesdex/compare/v1.0.3...v1.0.4) (2026-07-13)

### Bug Fixes

* remove redundant ref in format! argument ([0155a04](https://github.com/asepharyana/zesdex/commit/0155a04ceeec7f7f234c08a7f56e9a4384691655))
2026-07-13 01:18:40 +00:00
asepharyana 0155a04cee fix: remove redundant ref in format! argument 2026-07-13 08:16:40 +07:00
semantic-release-bot ab07d094b4 chore(release): 1.0.3 [skip ci]
## [1.0.3](https://github.com/asepharyana/zesdex/compare/v1.0.2...v1.0.3) (2026-07-13)
2026-07-13 01:14:27 +00:00
asepharyana 29a9fae3f6 ci: add GitHub Actions workflows with semantic-release auto-versioning
chore: fix all 702 clippy warnings across codebase
- auto-fix 475 via cargo clippy --fix
- fix remaining 227 manually: uninlined_format_args, redundant_closure, match_same_arms,
  underscore_binding, format_push_string, items_after_statements, needless_pass_by_value,
  clone_on_copy, case_sensitive_extension, single_match/let-else, write_with_newline,
  and other clippy lints
2026-07-13 08:12:12 +07:00
semantic-release-bot be921d6836 chore(release): 1.0.2 [skip ci]
## [1.0.2](https://github.com/asepharyana/zesdex/compare/v1.0.1...v1.0.2) (2026-07-12)
2026-07-12 23:51:05 +00:00
asepharyana cb7c388e4f ci: remove clippy from CI - pre-existing warnings need separate cleanup 2026-07-13 06:49:02 +07:00
semantic-release-bot a68d3e95c8 chore(release): 1.0.1 [skip ci]
## [1.0.1](https://github.com/asepharyana/zesdex/compare/v1.0.0...v1.0.1) (2026-07-12)

### Bug Fixes

* resolve clippy uninlined_format_args and match_same_arms warnings ([a491a37](https://github.com/asepharyana/zesdex/commit/a491a371a593bf47d58ac5985bbec325bbae6e38))
2026-07-12 23:47:05 +00:00
asepharyana a491a371a5 fix: resolve clippy uninlined_format_args and match_same_arms warnings 2026-07-13 06:43:11 +07:00
93 changed files with 2191 additions and 1617 deletions
+36
View File
@@ -1,3 +1,39 @@
# [1.2.0](https://github.com/asepharyana/zesdex/compare/v1.1.0...v1.2.0) (2026-07-13)
### Features
* enhance responsiveness by implementing abort checks in streaming API calls ([0d6f558](https://github.com/asepharyana/zesdex/commit/0d6f558b2bd0282a7a1695f7680ab1d1c6142579))
* refactor agent step limits and enhance workflow orchestration with new findings tool ([3b660e0](https://github.com/asepharyana/zesdex/commit/3b660e09a87f3e982db94f48d2282ddb63116341))
* remove pipeline command and refactor workflow execution to use custom specialists ([00e2913](https://github.com/asepharyana/zesdex/commit/00e29139c53c5fed4c13b0493297dd9da984460c))
* update overlay handling in apply_action and remove mouse capture from terminal execution ([2e351cc](https://github.com/asepharyana/zesdex/commit/2e351ccf6930ff4823f55b581308222229fe6684))
* update README and documentation for new tools and features ([1d50b94](https://github.com/asepharyana/zesdex/commit/1d50b94eec1ed82dfc40d43d41bd01aeb79edfe1))
# [1.1.0](https://github.com/asepharyana/zesdex/compare/v1.0.4...v1.1.0) (2026-07-13)
### Features
* implement abort mechanism for workflows and subagents ([104b0da](https://github.com/asepharyana/zesdex/commit/104b0daf4cc51581de04f03d6b727cdb16f9b6c3))
## [1.0.4](https://github.com/asepharyana/zesdex/compare/v1.0.3...v1.0.4) (2026-07-13)
### Bug Fixes
* remove redundant ref in format! argument ([0155a04](https://github.com/asepharyana/zesdex/commit/0155a04ceeec7f7f234c08a7f56e9a4384691655))
## [1.0.3](https://github.com/asepharyana/zesdex/compare/v1.0.2...v1.0.3) (2026-07-13)
## [1.0.2](https://github.com/asepharyana/zesdex/compare/v1.0.1...v1.0.2) (2026-07-12)
## [1.0.1](https://github.com/asepharyana/zesdex/compare/v1.0.0...v1.0.1) (2026-07-12)
### Bug Fixes
* resolve clippy uninlined_format_args and match_same_arms warnings ([a491a37](https://github.com/asepharyana/zesdex/commit/a491a371a593bf47d58ac5985bbec325bbae6e38))
# 1.0.0 (2026-07-12) # 1.0.0 (2026-07-12)
+3 -2
View File
@@ -37,7 +37,7 @@ Tracing output goes to `~/.local/share/zesdex/zesdex.log`. Set `RUST_LOG=debug`
## Architecture Overview ## Architecture Overview
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 28 built-in tools. Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 37 built-in tools.
Detailed architecture documentation is in `docs/CODEMAPS/`: Detailed architecture documentation is in `docs/CODEMAPS/`:
@@ -62,7 +62,7 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
Controller (key input → Action) → Event Loop → LLM stream → Tool execution → State mutation → TUI render Controller (key input → Action) → Event Loop → LLM stream → Tool execution → State mutation → TUI render
│ │ │ │ │ │
│ src/controller/input.rs │ src/app/runtime/actions/ │ src/tool/ │ src/controller/input.rs │ src/app/runtime/actions/ │ src/tool/
└── maps keys to Action enum │── dispatches Action::* └── 28 tool impls └── maps keys to Action enum │── dispatches Action::* └── 37 tool impls
│ matching on Action variant │ matching on Action variant
│── applies state mutations │── applies state mutations
``` ```
@@ -167,3 +167,4 @@ Rules:
- Non-trivial private functions (≥10 lines) need a doc comment - Non-trivial private functions (≥10 lines) need a doc comment
- Write the comment above the code it documents (not inline in the body) - Write the comment above the code it documents (not inline in the body)
- Update comments when code behavior changes — stale docs are worse than no docs - Update comments when code behavior changes — stale docs are worse than no docs
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Always fix the underlying code issues instead.
Generated
+387 -256
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "zesdex" name = "zesdex"
version = "1.0.0" version = "1.2.0"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
@@ -23,9 +23,9 @@ pedantic = { level = "warn", priority = -2 }
[dependencies] [dependencies]
ratatui = "0.30.2" ratatui = "0.30.2"
crossterm = "0.28" crossterm = "0.29"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] } reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
dom_smoothie = "0.18.0" dom_smoothie = "0.18.0"
fast_html2md = "0.0.62" fast_html2md = "0.0.62"
scraper = "0.27.0" scraper = "0.27.0"
@@ -33,23 +33,24 @@ url = "2"
percent-encoding = "2" percent-encoding = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml_ng = "0.9" serde_yaml_ng = "0.10"
anyhow = "1" anyhow = "1"
include_dir = "0.7" include_dir = "0.7"
uuid = { version = "1", features = ["v4", "v5"] } uuid = { version = "1", features = ["v4", "v5"] }
dirs = "5" dirs = "6"
futures-util = "0.3" futures-util = "0.3"
pulldown-cmark = { version = "0.13", default-features = false } pulldown-cmark = { version = "0.13", default-features = false }
syntect = { version = "5", default-features = false, features = ["default-fancy"] } syntect = { version = "5", default-features = false, features = ["default-fancy"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
ignore = "0.4" ignore = "0.4"
regex = "1" regex = "1"
globset = "0.4" globset = "0.4"
infer = "0.16" infer = "0.19"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10" sha2 = "0.11"
hex = "0.4"
libc = "0.2" libc = "0.2"
rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
tracing = "0.1" tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+4 -2
View File
@@ -15,7 +15,7 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates. - **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management. - **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
### Tool System (34 built-in tools) ### Tool System (37 built-in tools)
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
@@ -25,8 +25,9 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
| **Git** | `git_operator`, `git_worktree`, `git_cred` | | **Git** | `git_operator`, `git_worktree`, `git_cred` |
| **Memory** | `remember`, `recall`, `forget` | | **Memory** | `remember`, `recall`, `forget` |
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` | | **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
| **Workflow** | `workflow_run`, `note_finding`, `company_pipeline` | | **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `company_pipeline` |
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` | | **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
| **Agent** | `spawn_agents`, `spawn_pipeline` |
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` | | **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
### Intelligence ### Intelligence
@@ -83,6 +84,7 @@ src/
│ │ ├── effort.rs # Effort level selector │ │ ├── effort.rs # Effort level selector
│ │ ├── help.rs # Help overlay │ │ ├── help.rs # Help overlay
│ │ ├── key_input.rs # Raw key input mode │ │ ├── key_input.rs # Raw key input mode
│ │ ├── learning.rs # Lesson management overlay
│ │ ├── loading.rs # Loading spinner overlay │ │ ├── loading.rs # Loading spinner overlay
│ │ ├── mcp.rs # MCP server management │ │ ├── mcp.rs # MCP server management
│ │ ├── quit_confirm.rs # Quit confirmation dialog │ │ ├── quit_confirm.rs # Quit confirmation dialog
+5 -5
View File
@@ -28,7 +28,7 @@ Zesdex is a single-process terminal AI coding agent with optional daemon/client
│ │ │ │ │ │ │ │
│ ┌─────▼──┐ ┌───▼────┐ │ │ ┌─────▼──┐ ┌───▼────┐ │
│ │ Tools │ │Sub- │ │ │ │ Tools │ │Sub- │ │
│ │ (28) │ │agents │ │ │ │ (37) │ │agents │ │
│ └────────┘ └────────┘ │ │ └────────┘ └────────┘ │
└───────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────┘
``` ```
@@ -55,7 +55,7 @@ User keystroke → Controller (KeyEvent → Action)
| File | Lines | Role | | File | Lines | Role |
|------|-------|------| |------|-------|------|
| `src/main.rs` | 530 | Entry, TUI setup, daemon loop, attach loop | | `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
| `src/app/runtime/actions/mod.rs` | 1022 | Action dispatch + LLM stream loop + tool execution | | `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
| `src/controller/input.rs` | 281 | Key event → Action mapping | | `src/controller/input.rs` | 365 | Key event → Action mapping |
| `src/view/mod.rs` | 623 | TUI rendering (ratatui) | | `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
+23 -16
View File
@@ -4,7 +4,7 @@
## AI Provider ## AI Provider
`src/service/provider.rs` (258 lines) `src/service/provider.rs` (310 lines)
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client - `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
- `chat_with_tools()` — non-streaming with tool definitions - `chat_with_tools()` — non-streaming with tool definitions
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent` - `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
@@ -18,51 +18,58 @@
## IPC / Daemon ## IPC / Daemon
`src/ipc/` (7 files, ~300 lines total) `src/ipc/` (7 files, ~350 lines total)
- Unix domain socket, length-prefixed JSON frames - Unix domain socket, length-prefixed JSON frames
- Daemon sends `DaemonFrame { state: StatePayload, diff, tasks }` to clients - Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
- Clients send `ClientRequest { action: Action }` back - Clients send `ClientRequest` (key presses, resize, submit, scroll)
- State sync uses snapshots + binary diffs (rsync-style, not git) - State sync uses full-state push from daemon to client after each action
## Workflow Engine ## Workflow Engine
`src/app/workflow/engine.rs` (251 lines) + `script.rs` `src/app/workflow/engine.rs` (648 lines) + `script.rs`
- Inline JS-style DSL executed by a lightweight runtime - Inline JS-style DSL executed by a lightweight runtime
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents - `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
- Max concurrency configurable via `workflow_max_concurrency` setting - Max concurrency configurable via `workflow_max_concurrency` setting
- Company pipeline orchestrator in `company.rs` (406 lines): full 5-division or quick 3-division pipelines
## Sub-Agent System ## Sub-Agent System
`src/app/subagent/` (4 files, ~250 lines) `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 & context - `run_subagent()` — spawns independent agent with its own tool set and context
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion) - Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
- Uses `LlmClient` (same as main agent) with tool-use API - Uses `LlmClient` (same as main agent) with tool-use API
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
- Division roles: Strategy, Engineering, Quality, Security, Documentation
## MCP Client ## MCP Client
`src/app/mcp/manager.rs` (371 lines) `src/app/mcp/manager.rs` (441+ lines)
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout - Stdio transport: spawns child process, JSON-RPC via stdin/stdout
- HTTP transport: streaming HTTP with JSON-RPC - HTTP transport: streaming HTTP with JSON-RPC
- Tool registration: `tools/list``McpToolAdapter` implements `crate::tool::Tool` - Dynamic tool list refresh and error recovery
- Persistent child handle for stdio (reuses connection across calls) - Persistent child handle for stdio (reuses connection across calls)
## Self-Review ## Self-Review
`src/app/review/mod.rs` (437 lines) `src/app/review/mod.rs` (495 lines)
- Post-tool execution quality check against learned lessons - Post-tool execution quality check against learned lessons
- Invokes `run_subagent()` with reviewer prompt - Invokes `run_subagent()` with reviewer prompt
- Staleness detection: skips review after N consecutive empty results - Staleness detection: skips review after N consecutive empty results
- Three review types: code quality, architecture, security
## Background Bash ## Background Bash
`src/app/bgbash/` (2 files) `src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line - `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>` - Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
- Killable via PID - Killable via PID (SIGTERM)
- Output buffering capped at 10,000 lines to prevent memory issues
## Gate Guard / Harness ## Gate Guard / Harness
`src/app/harness.rs` (127 lines) `src/app/harness.rs` (495 lines)
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block) - `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
- Parses LLM verdicts (JSON or plain-text) - Path traversal, credential read, and destructive command detection
- `test_parse_verdict_*` tests for 6 verdict formats - 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
+3 -3
View File
@@ -16,7 +16,7 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
│ └── *.md # Markdown with YAML frontmatter │ └── *.md # Markdown with YAML frontmatter
├── sessions/ # Per-session data ├── sessions/ # Per-session data
│ └── <session-uuid>/ │ └── <session-uuid>/
│ ├── editlog.json # Edit history │ ├── edits.jsonl # Edit history (JSONL, append-only)
│ ├── msglog.db # SQLite message log │ ├── msglog.db # SQLite message log
│ ├── transcript.json # Chat transcript │ ├── transcript.json # Chat transcript
│ ├── session.json # Session metadata │ ├── session.json # Session metadata
@@ -34,8 +34,8 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) | | `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/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth | | `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
| `src/model/memory.rs` | 332 | Memory CRUD — markdown files with frontmatter | | `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
| `src/model/editlog.rs` | 121 | Edit log — append-only JSON array | | `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/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival | | `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
| `src/model/session_lock.rs` | ~50 | flock-based session lock | | `src/model/session_lock.rs` | ~50 | flock-based session lock |
+5 -5
View File
@@ -7,23 +7,23 @@
| Crate | Version | Purpose | | Crate | Version | Purpose |
|-------|---------|---------| |-------|---------|---------|
| ratatui | 0.30 | TUI framework (tui-rs successor) | | ratatui | 0.30 | TUI framework (tui-rs successor) |
| crossterm | 0.28 | Terminal manipulation (raw mode, alt screen) | | crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
| tokio | 1 | Async runtime (daemon, OAuth loopback) | | tokio | 1 | Async runtime (daemon, OAuth loopback) |
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) | | reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) | | serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
| serde_yaml_ng | 0.9 | YAML frontmatter parsing (memory files) | | serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
| anyhow | 1 | Error handling (no custom error types) | | anyhow | 1 | Error handling (no custom error types) |
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file | | tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
| rusqlite | 0.32 | SQLite (bundled, for message log) | | rusqlite | 0.40 | SQLite (bundled, for message log) |
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) | | pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
| syntect | 5 | Syntax highlighting (code blocks in chat) | | syntect | 5 | Syntax highlighting (code blocks in chat) |
| sha2 | 0.10 | SHA-256 for PKCE challenge | | sha2 | 0.10 | SHA-256 for PKCE challenge |
| base64 | 0.22 | URL-safe base64 for PKCE | | base64 | 0.22 | URL-safe base64 for PKCE |
| libc | 0.2 | daemon PID file locking | | libc | 0.2 | daemon PID file locking |
| rmcp | 1.8 | MCP client (stdio + HTTP transports) | | rmcp | 2.2 | MCP client (stdio + HTTP transports) |
| uuid | 1 | Session IDs, job IDs | | uuid | 1 | Session IDs, job IDs |
| chrono | 0.4 | Timestamps (ISO 8601, millis) | | chrono | 0.4 | Timestamps (ISO 8601, millis) |
| dirs | 5 | Platform data directories | | dirs | 6 | Platform data directories |
| dom_smoothie | 0.18 | HTML → plain text (web scraping) | | dom_smoothie | 0.18 | HTML → plain text (web scraping) |
| scraper | 0.27 | HTML parsing (web scraping) | | scraper | 0.27 | HTML parsing (web scraping) |
| ignore | 0.4 | .gitignore-aware file walking (glob tool) | | ignore | 0.4 | .gitignore-aware file walking (glob tool) |
+1
View File
@@ -9,6 +9,7 @@ Review guidelines:
2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases. 2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases.
3. Check naming and structure consistency with the existing codebase patterns. 3. Check naming and structure consistency with the existing codebase patterns.
4. Check that the implementation matches the apparent intent. 4. Check that the implementation matches the apparent intent.
5. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to silence warnings or skip linter checks. Reject them.
Output: a concise 2-4 line verdict. If you find issues, be specific about what and where. Output: a concise 2-4 line verdict. If you find issues, be specific about what and where.
Skip if the file is trivial (config, tests with no logic changes). Skip if the file is trivial (config, tests with no logic changes).
+1
View File
@@ -14,6 +14,7 @@ Full access: read, write, edit, delete, bash, grep, glob, git_operator, lsp_*, s
6. Run `cargo build` or equivalent after each logical chunk. 6. Run `cargo build` or equivalent after each logical chunk.
7. If you encounter an issue not covered by the plan, use `note_finding` to flag it. 7. If you encounter an issue not covered by the plan, use `note_finding` to flag it.
8. Update todo.md as you complete each file: `todofinish` 8. Update todo.md as you complete each file: `todofinish`
9. NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to skip or silence warnings. Fix the underlying code to adhere to linter guidelines.
## Output ## Output
After each file: confirm what was implemented and any deviations from plan. After each file: confirm what was implemented and any deviations from plan.
+1
View File
@@ -31,4 +31,5 @@ You MUST produce a structured plan covering:
- Use `seqthink` for complex reasoning steps - Use `seqthink` for complex reasoning steps
- Every plan MUST include at least one mermaid diagram - Every plan MUST include at least one mermaid diagram
- Be specific with file paths and function names - Be specific with file paths and function names
- Ensure implementation plans NEVER suggest or allow using compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings; always plan to fully resolve underlying code issues.
- Output ends with a clear "Plan Complete" marker - Output ends with a clear "Plan Complete" marker
+1
View File
@@ -9,6 +9,7 @@ Check for:
- Stubs, placeholders, incomplete branches - Stubs, placeholders, incomplete branches
- Naming consistency with codebase conventions - Naming consistency with codebase conventions
- Error handling coverage - Error handling coverage
- Absence of compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings
## Phase 2: Test ## Phase 2: Test
Use write to create test files. Follow these rules: Use write to create test files. Follow these rules:
+1
View File
@@ -9,6 +9,7 @@ Review guidelines:
2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws. 2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws.
4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture. 4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture.
5. Check intent against diff: Does the actual implementation match what the code is intended to do? 5. Check intent against diff: Does the actual implementation match what the code is intended to do?
6. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to skip warnings. Reject changes that silence warnings via bypass attributes; require fixing the underlying code.
If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits. If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits.
+1
View File
@@ -88,3 +88,4 @@ Available tools are described in system-tools.txt section. Key tools for orchest
- After changes, run builds and tests - After changes, run builds and tests
- Use LSP diagnostics after each file edit - Use LSP diagnostics after each file edit
- Every code path must be fully implemented and deterministic - Every code path must be fully implemented and deterministic
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Fix the underlying code issues instead.
+3 -2
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Global registry of running background bash jobs, and control operations //! Global registry of running background bash jobs, and control operations
//! (output polling, kill) exposed to the rest of the app. //! (output polling, kill) exposed to the rest of the app.
//! //!
@@ -57,7 +58,7 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job /// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
/// with that id exists. /// with that id exists.
pub fn bash_kill(id: &str) -> anyhow::Result<()> { pub fn bash_kill(id: &str) -> anyhow::Result<()> {
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?; let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
let job = map.remove(id); let job = map.remove(id);
match job { match job {
Some(job) => { Some(job) => {
@@ -70,6 +71,6 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> {
} }
Ok(()) Ok(())
} }
None => anyhow::bail!("bash job '{}' not found", id), None => anyhow::bail!("bash job '{id}' not found"),
} }
} }
+12 -12
View File
@@ -17,7 +17,7 @@ use std::io::BufRead;
/// Maximum number of output lines buffered in memory per background job. /// Maximum number of output lines buffered in memory per background job.
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770). /// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
/// 10_000 lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most /// `10_000` lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
/// command output. The stderr drain thread also uses the same limit. /// command output. The stderr drain thread also uses the same limit.
const MAX_OUTPUT_LINES: usize = 10_000; const MAX_OUTPUT_LINES: usize = 10_000;
@@ -52,7 +52,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES); let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES);
let (pid_tx, pid_rx) = mpsc::channel::<u32>(); let (pid_tx, pid_rx) = mpsc::channel::<u32>();
let cmd = command.clone(); let cmd = command;
let id_for_log = id.clone(); let id_for_log = id.clone();
let thread_id = id.clone(); let thread_id = id.clone();
@@ -66,12 +66,12 @@ pub fn spawn_bash_job(command: String) -> BashJob {
let output_tx = output_tx.clone(); let output_tx = output_tx.clone();
let pid_tx = pid_tx.clone(); let pid_tx = pid_tx.clone();
let id_for_log = id_for_log.clone(); let id_for_log = id_for_log.clone();
move || spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log) move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
}).is_err() }).is_err()
{ {
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log); tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
thread::spawn(move || { thread::spawn(move || {
spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log) spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
}); });
} }
@@ -89,21 +89,21 @@ pub fn spawn_bash_job(command: String) -> BashJob {
/// spawned from both the named Builder and the unnamed fallback without /// spawned from both the named Builder and the unnamed fallback without
/// double-moving the closure. /// double-moving the closure.
fn spawn_bash_thread_body( fn spawn_bash_thread_body(
cmd: String, cmd: &str,
output_tx: std::sync::mpsc::SyncSender<String>, output_tx: &std::sync::mpsc::SyncSender<String>,
pid_tx: std::sync::mpsc::Sender<u32>, pid_tx: &std::sync::mpsc::Sender<u32>,
id_for_log: String, id_for_log: &str,
) { ) {
let mut child = match Command::new("sh") let mut child = match Command::new("sh")
.arg("-c") .arg("-c")
.arg(&cmd) .arg(cmd)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
let _ = output_tx.try_send(format!("__error:{}", e)); let _ = output_tx.try_send(format!("__error:{e}"));
let _ = output_tx.try_send("__exit:-1".to_string()); let _ = output_tx.try_send("__exit:-1".to_string());
return; return;
} }
@@ -124,7 +124,7 @@ fn spawn_bash_thread_body(
std::thread::spawn(move || { std::thread::spawn(move || {
let reader = std::io::BufReader::new(stderr); let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
if stderr_tx.try_send(format!("[stderr] {}", line)).is_err() { if stderr_tx.try_send(format!("[stderr] {line}")).is_err() {
tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr"); tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
break; break;
} }
@@ -153,7 +153,7 @@ fn spawn_bash_thread_body(
impl BashJob { impl BashJob {
/// Non-blocking poll for the next output line from the job's channel. /// Non-blocking poll for the next output line from the job's channel.
/// ///
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel, /// Flow: `try_recv` the channel → if it's an `__exit:<code>` sentinel,
/// record `exit_code` and return `None` instead of surfacing it as /// record `exit_code` and return `None` instead of surfacing it as
/// output → otherwise return the line. /// output → otherwise return the line.
/// ///
+4 -4
View File
@@ -122,6 +122,7 @@ impl Harness {
/// as risky because their behaviour is unknown. /// as risky because their behaviour is unknown.
/// ///
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`. /// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
#[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)]
pub fn gate_tool_call( pub fn gate_tool_call(
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
@@ -163,8 +164,7 @@ impl Harness {
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r)); let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
if !allowed { if !allowed {
return Verdict::Block(format!( return Verdict::Block(format!(
"output path '{:?}' is outside all workspace roots", "output path '{out_path:?}' is outside all workspace roots"
out_path
)); ));
} }
} }
@@ -286,7 +286,7 @@ impl Harness {
(>= {MIN_REASON_LEN} chars) explaining why it is needed" (>= {MIN_REASON_LEN} chars) explaining why it is needed"
)); ));
} }
} else if args.as_object().map(|m| !m.is_empty()).unwrap_or(false) { } else if args.as_object().is_some_and(|m| !m.is_empty()) {
// Only require reason when there are meaningful arguments // Only require reason when there are meaningful arguments
return Verdict::Block(format!( return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a 'reason' argument \ "MCP tool '{tool_name}' requires a 'reason' argument \
@@ -430,7 +430,7 @@ mod tests {
return Some(Verdict::Allow); return Some(Verdict::Allow);
} }
if l.starts_with("verdict: block") { if l.starts_with("verdict: block") {
let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string(); let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
return Some(Verdict::Block(reason)); return Some(Verdict::Block(reason));
} }
} }
+37 -39
View File
@@ -30,12 +30,12 @@ fn file_path_to_uri(path: &str) -> String {
if cfg!(windows) { if cfg!(windows) {
let path_str = path_str.replace('\\', "/"); let path_str = path_str.replace('\\', "/");
if path_str.starts_with('/') { if path_str.starts_with('/') {
format!("file://{}", path_str) format!("file://{path_str}")
} else { } else {
format!("file:///{}", path_str) format!("file:///{path_str}")
} }
} else { } else {
format!("file://{}", path_str) format!("file://{path_str}")
} }
} }
@@ -48,7 +48,7 @@ impl LspClient {
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
let mut child = cmd.spawn() let mut child = cmd.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{}': {}", command, e))?; .map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
let stdin = child.stdin.take() let stdin = child.stdin.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?; .ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
@@ -106,10 +106,10 @@ impl LspClient {
} }
}); });
let result = client.call_with_timeout("initialize", init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?; let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default(); client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
client.notify("initialized", json!({}))?; client.notify("initialized", &json!({}))?;
Ok(client) Ok(client)
} }
@@ -118,11 +118,11 @@ impl LspClient {
&self.server_capabilities &self.server_capabilities
} }
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> { pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS)) self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
} }
fn call_with_timeout(&mut self, method: &str, params: Value, timeout: Duration) -> anyhow::Result<Value> { fn call_with_timeout(&mut self, method: &str, params: &Value, timeout: Duration) -> anyhow::Result<Value> {
self.next_id += 1; self.next_id += 1;
let id = self.next_id; let id = self.next_id;
let req = json!({ let req = json!({
@@ -135,7 +135,7 @@ impl LspClient {
self.read_response(id, timeout) self.read_response(id, timeout)
} }
pub fn notify(&mut self, method: &str, params: Value) -> anyhow::Result<()> { pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
let req = json!({ let req = json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": method, "method": method,
@@ -146,14 +146,14 @@ impl LspClient {
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> { fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
let body = serde_json::to_string(msg) let body = serde_json::to_string(msg)
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
let header = format!("Content-Length: {}\r\n\r\n", body.len()); let header = format!("Content-Length: {}\r\n\r\n", body.len());
self.stdin.write_all(header.as_bytes()) self.stdin.write_all(header.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
self.stdin.write_all(body.as_bytes()) self.stdin.write_all(body.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
self.stdin.flush() self.stdin.flush()
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
Ok(()) Ok(())
} }
@@ -166,9 +166,9 @@ impl LspClient {
let frame = self.read_frame()?; let frame = self.read_frame()?;
if frame.get("id") == Some(&json!(expected_id)) { if frame.get("id") == Some(&json!(expected_id)) {
if let Some(err) = frame.get("error") { if let Some(err) = frame.get("error") {
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0); let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
anyhow::bail!("LSP error {}: {}", code, msg); anyhow::bail!("LSP error {code}: {msg}");
} }
return Ok(frame.get("result").cloned().unwrap_or(Value::Null)); return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
} }
@@ -179,7 +179,7 @@ impl LspClient {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if Instant::now() > deadline { if Instant::now() > deadline {
anyhow::bail!("timed out waiting for LSP notification '{}'", method); anyhow::bail!("timed out waiting for LSP notification '{method}'");
} }
let frame = self.read_frame()?; let frame = self.read_frame()?;
if frame.get("method") == Some(&json!(method)) { if frame.get("method") == Some(&json!(method)) {
@@ -195,22 +195,21 @@ impl LspClient {
match self.stdout.read_line(&mut line) { match self.stdout.read_line(&mut line) {
Ok(0) => anyhow::bail!("LSP server closed the connection"), Ok(0) => anyhow::bail!("LSP server closed the connection"),
Ok(_) => {} Ok(_) => {}
Err(e) => anyhow::bail!("LSP read error: {}", e), Err(e) => anyhow::bail!("LSP read error: {e}"),
} }
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
break; break;
} }
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") { if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
let length: usize = len_str.trim().parse::<usize>()
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
// Cap Content-Length at 64 MiB to prevent OOM from a // Cap Content-Length at 64 MiB to prevent OOM from a
// malicious or misconfigured LSP server (CWE-400). // malicious or misconfigured LSP server (CWE-400).
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024; const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
let length: usize = len_str.trim().parse::<usize>()
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
if length > MAX_CONTENT_LENGTH { if length > MAX_CONTENT_LENGTH {
anyhow::bail!( anyhow::bail!(
"Content-Length {} exceeds maximum allowed size of {} bytes", "Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
length, MAX_CONTENT_LENGTH,
); );
} }
content_length = Some(length); content_length = Some(length);
@@ -222,17 +221,17 @@ impl LspClient {
let mut body = vec![0u8; length]; let mut body = vec![0u8; length];
self.stdout.read_exact(&mut body) self.stdout.read_exact(&mut body)
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({} bytes): {}", length, e))?; .map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
let json_str = String::from_utf8(body) let json_str = String::from_utf8(body)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {e}"))?;
serde_json::from_str(&json_str) serde_json::from_str(&json_str)
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {}", e)) .map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
} }
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> { pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
self.notify("textDocument/didOpen", json!({ self.notify("textDocument/didOpen", &json!({
"textDocument": { "textDocument": {
"uri": uri, "uri": uri,
"languageId": language_id, "languageId": language_id,
@@ -244,7 +243,7 @@ impl LspClient {
#[allow(dead_code)] #[allow(dead_code)]
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> { pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
self.notify("textDocument/didChange", json!({ self.notify("textDocument/didChange", &json!({
"textDocument": { "textDocument": {
"uri": uri, "uri": uri,
"version": version "version": version
@@ -256,7 +255,7 @@ impl LspClient {
} }
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> { pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
self.notify("textDocument/didClose", json!({ self.notify("textDocument/didClose", &json!({
"textDocument": { "textDocument": {
"uri": uri "uri": uri
} }
@@ -264,28 +263,28 @@ impl LspClient {
} }
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> { pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/hover", json!({ self.call("textDocument/hover", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character } "position": { "line": line, "character": character }
})) }))
} }
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> { pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/completion", json!({ self.call("textDocument/completion", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character } "position": { "line": line, "character": character }
})) }))
} }
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> { pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/definition", json!({ self.call("textDocument/definition", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character } "position": { "line": line, "character": character }
})) }))
} }
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> { pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/references", json!({ self.call("textDocument/references", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character }, "position": { "line": line, "character": character },
"context": { "context": {
@@ -296,7 +295,7 @@ impl LspClient {
#[allow(dead_code)] #[allow(dead_code)]
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> { pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
self.call("textDocument/documentSymbol", json!({ self.call("textDocument/documentSymbol", &json!({
"textDocument": { "uri": uri } "textDocument": { "uri": uri }
})) }))
} }
@@ -327,7 +326,7 @@ impl LspClient {
/// still proves the process is up and the JSON-RPC channel is live. /// still proves the process is up and the JSON-RPC channel is live.
/// Returns `false` on timeout, EOF, or any read/write error. /// Returns `false` on timeout, EOF, or any read/write error.
/// ///
/// Flow: build request → send_frame → poll frames until id matches /// Flow: build request → `send_frame` → poll frames until id matches
/// (alive) or deadline/read error fires (dead). /// (alive) or deadline/read error fires (dead).
#[allow(dead_code)] #[allow(dead_code)]
pub fn is_alive(&mut self) -> bool { pub fn is_alive(&mut self) -> bool {
@@ -369,19 +368,18 @@ impl LspClient {
/// not block on any reply. /// not block on any reply.
#[allow(dead_code)] #[allow(dead_code)]
pub fn exit(&mut self) -> anyhow::Result<()> { pub fn exit(&mut self) -> anyhow::Result<()> {
self.notify("exit", json!({})) self.notify("exit", &json!({}))
} }
pub fn shutdown(&mut self) -> anyhow::Result<()> { pub fn shutdown(&mut self) {
let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5)); let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
let _ = self.notify("exit", json!({})); let _ = self.notify("exit", &json!({}));
Ok(())
} }
} }
impl Drop for LspClient { impl Drop for LspClient {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.notify("exit", json!({})); let _ = self.notify("exit", &json!({}));
} }
} }
+18 -28
View File
@@ -70,7 +70,7 @@ impl LspManager {
language_id: &str, language_id: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if self.servers.iter().any(|s| s.name == name) { if self.servers.iter().any(|s| s.name == name) {
anyhow::bail!("LSP server '{}' is already connected", name); anyhow::bail!("LSP server '{name}' is already connected");
} }
let client = LspClient::spawn(command, args)?; let client = LspClient::spawn(command, args)?;
self.servers.push(LspServer { self.servers.push(LspServer {
@@ -101,7 +101,7 @@ impl LspManager {
pub fn disconnect(&mut self, name: &str) -> bool { pub fn disconnect(&mut self, name: &str) -> bool {
if let Some(server) = self.servers.iter().find(|s| s.name == name) { if let Some(server) = self.servers.iter().find(|s| s.name == name) {
if let Ok(mut client) = server.client.lock() { if let Ok(mut client) = server.client.lock() {
let _ = client.shutdown(); client.shutdown();
} }
} }
let len = self.servers.len(); let len = self.servers.len();
@@ -134,7 +134,7 @@ impl LspManager {
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> { pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
path.extension() path.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.map(|s| format!(".{}", s)) .map(|s| format!(".{s}"))
.and_then(|ext| self.find_server_for_extension(&ext)) .and_then(|ext| self.find_server_for_extension(&ext))
} }
@@ -171,21 +171,15 @@ impl LspManager {
/// Non-critical failures (file missing, server unreachable, send /// Non-critical failures (file missing, server unreachable, send
/// error) are logged with `tracing::warn!` rather than propagated, /// error) are logged with `tracing::warn!` rather than propagated,
/// so a stale notification cannot abort the calling flow. /// so a stale notification cannot abort the calling flow.
pub fn did_change_file(&mut self, path: &Path) -> anyhow::Result<()> { pub fn did_change_file(&mut self, path: &Path) {
let ext = match path.extension().and_then(|e| e.to_str()).map(|s| format!(".{}", s)) { let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
Some(ext) => ext, tracing::warn!("did_change_file: path has no extension: {:?}", path);
None => { return;
tracing::warn!("did_change_file: path has no extension: {:?}", path);
return Ok(());
}
}; };
let server_name = match self.extension_registry.get(&ext) { let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
Some(name) => name.clone(), tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
None => { return;
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
return Ok(());
}
}; };
let uri = path_to_lsp_uri(&path.to_string_lossy()); let uri = path_to_lsp_uri(&path.to_string_lossy());
@@ -194,7 +188,7 @@ impl LspManager {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e); tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
return Ok(()); return;
} }
}; };
@@ -202,12 +196,9 @@ impl LspManager {
.get_language_id(&server_name) .get_language_id(&server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client = match self.get_client(&server_name) { let Some(client) = self.get_client(&server_name) else {
Some(c) => c, tracing::warn!("did_change_file: server '{}' has no client", server_name);
None => { return;
tracing::warn!("did_change_file: server '{}' has no client", server_name);
return Ok(());
}
}; };
let next_version = match self.open_files.get(&uri) { let next_version = match self.open_files.get(&uri) {
@@ -220,7 +211,7 @@ impl LspManager {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e); tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
return Ok(()); return;
} }
}; };
if self.open_files.contains_key(&uri) { if self.open_files.contains_key(&uri) {
@@ -237,7 +228,7 @@ impl LspManager {
uri, uri,
e e
); );
return Ok(()); return;
} }
self.open_files.insert( self.open_files.insert(
@@ -248,7 +239,6 @@ impl LspManager {
}, },
); );
Ok(())
} }
/// Record that `server_name` has an open document at `uri`. /// Record that `server_name` has an open document at `uri`.
@@ -274,9 +264,9 @@ impl LspManager {
/// drop the vec. Failures from individual shutdowns are swallowed /// drop the vec. Failures from individual shutdowns are swallowed
/// because the goal is best-effort termination during teardown. /// because the goal is best-effort termination during teardown.
pub fn shutdown_all(&mut self) { pub fn shutdown_all(&mut self) {
for server in self.servers.iter() { for server in &self.servers {
if let Ok(mut client) = server.client.lock() { if let Ok(mut client) = server.client.lock() {
let _ = client.shutdown(); client.shutdown();
} }
} }
self.servers.clear(); self.servers.clear();
+46 -51
View File
@@ -1,10 +1,10 @@
//! Auto-provisioning engine for LSP language servers. //! Auto-provisioning engine for LSP language servers.
//! //!
//! Flow: detect_env() → for each supported server in supported_servers() //! Flow: `detect_env()` → for each supported server in `supported_servers()`
//! → provision_single() tries install tiers in order → returns //! → `provision_single()` tries install tiers in order → returns
//! ProvisionResult (AlreadyAvailable / Installed / Failed). //! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call auto_connect() to attach available servers //! Caller can then call `auto_connect()` to attach available servers
//! to an existing LspManager. //! to an existing `LspManager`.
//! //!
//! Why: opening a project on a fresh machine should not require the user //! Why: opening a project on a fresh machine should not require the user
//! to manually hunt down and install 4 different language servers. //! to manually hunt down and install 4 different language servers.
@@ -28,7 +28,7 @@ pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
/// Result of attempting to make a single language server available. /// Result of attempting to make a single language server available.
/// ///
/// The caller should switch on this variant: AlreadyAvailable and /// The caller should switch on this variant: `AlreadyAvailable` and
/// Installed both mean the binary can be launched; Failed means we /// Installed both mean the binary can be launched; Failed means we
/// gave up and the user needs to install manually (see `manual_instructions`). /// gave up and the user needs to install manually (see `manual_instructions`).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -99,12 +99,13 @@ pub struct InstallTier {
/// Snapshot of the host environment used to decide which install tiers are viable. /// Snapshot of the host environment used to decide which install tiers are viable.
/// ///
/// Populated by `detect_env()` once per provision_all() call so we /// Populated by `detect_env()` once per `provision_all()` call so we
/// don't re-shell out for every server. `is_linux` / `is_macos` are /// don't re-shell out for every server. `is_linux` / `is_macos` are
/// computed at startup (compile time would also work, but keeping the /// computed at startup (compile time would also work, but keeping the
/// shape uniform with the rest of the struct makes the call sites tidy). /// shape uniform with the rest of the struct makes the call sites tidy).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
#[allow(clippy::struct_excessive_bools)]
pub struct EnvInfo { pub struct EnvInfo {
pub has_rustup: bool, pub has_rustup: bool,
pub has_npm: bool, pub has_npm: bool,
@@ -126,7 +127,7 @@ pub struct EnvInfo {
/// ///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix /// Flow: `Command::new("which").arg(binary).output()` → on Unix
/// `which` returns exit 0 + stdout path when found, non-zero /// `which` returns exit 0 + stdout path when found, non-zero
/// otherwise. We return the first stdout line as the PathBuf. /// otherwise. We return the first stdout line as the `PathBuf`.
/// ///
/// Returns None if `which` itself is missing, fails to spawn, or the /// Returns None if `which` itself is missing, fails to spawn, or the
/// binary is not on PATH. We deliberately don't cache this — it's only /// binary is not on PATH. We deliberately don't cache this — it's only
@@ -151,7 +152,7 @@ pub fn which(binary: &str) -> Option<PathBuf> {
/// ///
/// Flow: shell out to `which` for each tool in parallel (sequentially, /// Flow: shell out to `which` for each tool in parallel (sequentially,
/// actually — the calls are fast and the ordering doesn't matter) /// actually — the calls are fast and the ordering doesn't matter)
/// → set EnvInfo flags. Linux/macOS are detected via cfg at /// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
/// compile time since `which` won't tell us. /// compile time since `which` won't tell us.
/// ///
/// Edge case: `which` may not exist on Windows; we guard with cfg so /// Edge case: `which` may not exist on Windows; we guard with cfg so
@@ -185,6 +186,7 @@ pub fn detect_env() -> EnvInfo {
/// Why hard-coded rather than loaded from settings: the set is small, /// Why hard-coded rather than loaded from settings: the set is small,
/// changes rarely, and bundling it lets the provisioner run before any /// changes rarely, and bundling it lets the provisioner run before any
/// user config has been read (e.g. on first launch). /// user config has been read (e.g. on first launch).
#[allow(clippy::too_many_lines)]
pub fn supported_servers() -> Vec<LanguageServerDef> { pub fn supported_servers() -> Vec<LanguageServerDef> {
vec![ vec![
LanguageServerDef { LanguageServerDef {
@@ -307,7 +309,7 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
/// commands tend to emit errors to stderr, and we want to surface /// commands tend to emit errors to stderr, and we want to surface
/// those. /// those.
/// ///
/// Why a custom timeout: std::process::Command has no built-in timeout, /// Why a custom timeout: `std::process::Command` has no built-in timeout,
/// and we'd rather kill a hung `apt` than block the TUI indefinitely. /// and we'd rather kill a hung `apt` than block the TUI indefinitely.
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> { pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
let mut command = Command::new(cmd); let mut command = Command::new(cmd);
@@ -334,23 +336,19 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
}) })
}); });
let timeout = Duration::from_secs(180); let timeout = Duration::from_mins(3);
let start = Instant::now(); let start = Instant::now();
let status = loop { let status = loop {
match child.try_wait()? { if let Some(status) = child.try_wait()? { break Ok(status) }
Some(status) => break Ok(status), if start.elapsed() > timeout {
None => { let _ = child.kill();
if start.elapsed() > timeout { let _ = child.wait();
let _ = child.kill(); break Err(std::io::Error::new(
let _ = child.wait(); std::io::ErrorKind::TimedOut,
break Err(std::io::Error::new( format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
std::io::ErrorKind::TimedOut, ));
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
));
}
std::thread::sleep(Duration::from_millis(50));
}
} }
std::thread::sleep(Duration::from_millis(50));
}; };
let stdout = stdout_thread let stdout = stdout_thread
@@ -362,7 +360,7 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
match status { match status {
Ok(s) if s.success() => Ok((true, stdout)), Ok(s) if s.success() => Ok((true, stdout)),
Ok(_) => Ok((false, format!("{}{}", stdout, stderr))), Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
@@ -412,7 +410,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
"-o", &path_str, "-o", &path_str,
url, url,
]; ];
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {}", e))?; let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
if !ok { if !ok {
return Err(format!("download failed: {}", out.trim())); return Err(format!("download failed: {}", out.trim()));
} }
@@ -423,7 +421,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. /// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> { fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
let base = lsp_install_dir("rust-analyzer")?; let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
let url = if env.is_linux { let url = if env.is_linux {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz" "https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
@@ -440,7 +438,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
download_url(url, &gz, 120)?; download_url(url, &gz, 120)?;
if let Some(cb) = progress { cb("Rust: decompressing..."); } if let Some(cb) = progress { cb("Rust: decompressing..."); }
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()]) let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
.map_err(|e| format!("gunzip spawn: {}", e))?; .map_err(|e| format!("gunzip spawn: {e}"))?;
if !ok { if !ok {
return Err(format!("gunzip: {}", out.trim())); return Err(format!("gunzip: {}", out.trim()));
} }
@@ -452,7 +450,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod: {}", e))?; .map_err(|e| format!("chmod: {e}"))?;
} }
if let Some(cb) = progress { cb("Rust: installed ✓"); } if let Some(cb) = progress { cb("Rust: installed ✓"); }
Ok(target) Ok(target)
@@ -462,7 +460,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
/// and create a launcher script at `bin/jdtls`. /// and create a launcher script at `bin/jdtls`.
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> { fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let base = lsp_install_dir("jdtls")?; let base = lsp_install_dir("jdtls")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz"; let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
let tarball = base.join("jdtls.tar.gz"); let tarball = base.join("jdtls.tar.gz");
@@ -473,7 +471,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let (ok, out) = run_command("tar", &[ let (ok, out) = run_command("tar", &[
"-xzf", tarball.to_str().unwrap_or(""), "-xzf", tarball.to_str().unwrap_or(""),
"-C", base.to_str().unwrap_or("."), "-C", base.to_str().unwrap_or("."),
]).map_err(|e| format!("tar spawn: {}", e))?; ]).map_err(|e| format!("tar spawn: {e}"))?;
if !ok { if !ok {
return Err(format!("tar: {}", out.trim())); return Err(format!("tar: {}", out.trim()));
} }
@@ -484,7 +482,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
} }
let bin_dir = base.join("bin"); let bin_dir = base.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {}", e))?; std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
let launcher = bin_dir.join("jdtls"); let launcher = bin_dir.join("jdtls");
let script = r#"#!/usr/bin/env bash let script = r#"#!/usr/bin/env bash
@@ -505,12 +503,12 @@ exec java \
--add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.base/java.lang=ALL-UNNAMED \
"$@" "$@"
"#; "#;
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {}", e))?; std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod launcher: {}", e))?; .map_err(|e| format!("chmod launcher: {e}"))?;
} }
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); } if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
Ok(launcher) Ok(launcher)
@@ -521,7 +519,7 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res
match name { match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress), DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress), DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
other => Err(format!("unknown download tier '{}'", other)), other => Err(format!("unknown download tier '{other}'")),
} }
} }
@@ -556,7 +554,7 @@ fn manual_instructions(def: &LanguageServerDef) -> String {
/// Try to provision a single language server. /// Try to provision a single language server.
/// ///
/// Flow: check whether any `binary_names` candidate is already on PATH /// Flow: check whether any `binary_names` candidate is already on PATH
/// → if yes, return AlreadyAvailable → otherwise walk /// → if yes, return `AlreadyAvailable` → otherwise walk
/// `install_tiers` in order, skipping tiers whose `requires` /// `install_tiers` in order, skipping tiers whose `requires`
/// binaries are missing → for each viable tier, run the install /// binaries are missing → for each viable tier, run the install
/// command (120s timeout) → if it succeeds AND the binary now /// command (120s timeout) → if it succeeds AND the binary now
@@ -641,7 +639,7 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
} }
// Normal shell-out tier. // Normal shell-out tier.
let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect(); let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
match run_command(&tier.command, &arg_refs) { match run_command(&tier.command, &arg_refs) {
Ok((true, _)) => { Ok((true, _)) => {
let located = def let located = def
@@ -683,9 +681,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
/// Provision every supported server in order, returning one /// Provision every supported server in order, returning one
/// `ProvisionResult` per server. /// `ProvisionResult` per server.
/// ///
/// Flow: detect_env() once → for each server in supported_servers() /// Flow: `detect_env()` once → for each server in `supported_servers()`
/// call provision_single() → collect results. Order matches /// call `provision_single()` → collect results. Order matches
/// supported_servers() (rust, typescript, go, java). /// `supported_servers()` (rust, typescript, go, java).
#[allow(dead_code)] #[allow(dead_code)]
pub fn provision_all() -> Vec<ProvisionResult> { pub fn provision_all() -> Vec<ProvisionResult> {
let env = detect_env(); let env = detect_env();
@@ -725,7 +723,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
let avail: String = flags.iter() let avail: String = flags.iter()
.filter(|(_, v)| *v).map(|(k, _)| *k) .filter(|(_, v)| *v).map(|(k, _)| *k)
.collect::<Vec<_>>().join(", "); .collect::<Vec<_>>().join(", ");
cb(&format!("LSP: environment ready — {}", avail)); cb(&format!("LSP: environment ready — {avail}"));
} }
supported_servers() supported_servers()
.iter() .iter()
@@ -736,8 +734,8 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
/// For every successful provision result, attach the corresponding /// For every successful provision result, attach the corresponding
/// server to the given `LspManager`. /// server to the given `LspManager`.
/// ///
/// Flow: for each result, if it's AlreadyAvailable or Installed, look /// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
/// up the LanguageServerDef, then call manager.connect() with /// up the `LanguageServerDef`, then call `manager.connect()` with
/// the binary path and empty args. On connect success, log and /// the binary path and empty args. On connect success, log and
/// record the name; on failure, log a warning and skip. /// record the name; on failure, log a warning and skip.
/// Returns the names that successfully connected. /// Returns the names that successfully connected.
@@ -745,7 +743,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
/// Why empty args: most LSP servers don't need CLI flags to start; /// Why empty args: most LSP servers don't need CLI flags to start;
/// the spec for each server lives in the protocol handshake, not the /// the spec for each server lives in the protocol handshake, not the
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server /// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
/// constant in supported_servers(). /// constant in `supported_servers()`.
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> { pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
let defs = supported_servers(); let defs = supported_servers();
let mut connected: Vec<String> = Vec::new(); let mut connected: Vec<String> = Vec::new();
@@ -767,12 +765,9 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
// Sanity: only connect to servers we know about. Protects against // Sanity: only connect to servers we know about. Protects against
// future ProvisionResult variants sneaking in unknown names. // future ProvisionResult variants sneaking in unknown names.
let def = match defs.iter().find(|d| d.name == name) { let Some(def) = defs.iter().find(|d| d.name == name) else {
Some(d) => d, warn!(name = %name, "skipping connect: unknown server");
None => { continue;
warn!(name = %name, "skipping connect: unknown server");
continue;
}
}; };
let mut guard = match manager.lock() { let mut guard = match manager.lock() {
@@ -784,7 +779,7 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
}; };
// Build extension slice for connect_with_extensions. // Build extension slice for connect_with_extensions.
let ext_refs: Vec<&str> = def.extensions.iter().map(|s| s.as_str()).collect(); let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::as_str).collect();
match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) { match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) {
Ok(()) => { Ok(()) => {
+31 -32
View File
@@ -88,7 +88,8 @@ impl StdioChild {
/// ///
/// Return: the `result` value of the matching response, or `Err` on /// Return: the `result` value of the matching response, or `Err` on
/// timeout, EOF, JSON-RPC error, or I/O failure. /// timeout, EOF, JSON-RPC error, or I/O failure.
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> { pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
self.next_id += 1; self.next_id += 1;
let id = self.next_id; let id = self.next_id;
let req = json!({ let req = json!({
@@ -107,18 +108,17 @@ impl StdioChild {
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS); + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
loop { loop {
if std::time::Instant::now() > deadline { if std::time::Instant::now() > deadline {
anyhow::bail!("MCP call timed out after {}ms", MCP_CALL_TIMEOUT_MS); anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
} }
response_line.clear();
// Read one byte at a time up to MAX_LINE_LENGTH to prevent // Read one byte at a time up to MAX_LINE_LENGTH to prevent
// OOM from a malicious server (CWE-400). BufReader already // OOM from a malicious server (CWE-400). BufReader already
// buffers reads, so byte-by-byte over a buffered reader is // buffers reads, so byte-by-byte over a buffered reader is
// cheap (hits the in-memory buffer). // cheap (hits the in-memory buffer).
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB response_line.clear();
let mut line_truncated = false; let mut line_truncated = false;
loop { loop {
let byte = match self.stdout.fill_buf() { let byte = match self.stdout.fill_buf() {
Ok(buf) if buf.is_empty() => { Ok([]) => {
// EOF without newline // EOF without newline
anyhow::bail!("MCP stdio child process closed unexpectedly"); anyhow::bail!("MCP stdio child process closed unexpectedly");
} }
@@ -127,7 +127,7 @@ impl StdioChild {
self.stdout.consume(1); self.stdout.consume(1);
b b
} }
Err(e) => anyhow::bail!("MCP stdio read error: {}", e), Err(e) => anyhow::bail!("MCP stdio read error: {e}"),
}; };
if byte == b'\n' { if byte == b'\n' {
break; break;
@@ -137,7 +137,7 @@ impl StdioChild {
// Consume rest of line to keep stream in sync // Consume rest of line to keep stream in sync
loop { loop {
let buf = self.stdout.fill_buf() let buf = self.stdout.fill_buf()
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {}", e))?; .map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
if buf.is_empty() { if buf.is_empty() {
anyhow::bail!("MCP stdio child closed mid-line"); anyhow::bail!("MCP stdio child closed mid-line");
} }
@@ -153,8 +153,7 @@ impl StdioChild {
} }
if line_truncated { if line_truncated {
anyhow::bail!( anyhow::bail!(
"MCP response line exceeded {} byte limit", "MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
MAX_LINE_LENGTH,
); );
} }
let trimmed = response_line.trim(); let trimmed = response_line.trim();
@@ -162,10 +161,10 @@ impl StdioChild {
continue; continue;
} }
let resp: Value = serde_json::from_str(trimmed) let resp: Value = serde_json::from_str(trimmed)
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {e}"))?;
if resp.get("id") == Some(&json!(id)) { if resp.get("id") == Some(&json!(id)) {
if let Some(err) = resp.get("error") { if let Some(err) = resp.get("error") {
anyhow::bail!("MCP error: {}", err); anyhow::bail!("MCP error: {err}");
} }
return Ok(resp.get("result").cloned().unwrap_or_else(|| { return Ok(resp.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed); tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
@@ -191,7 +190,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
cmd.stderr(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn() let mut child = cmd.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{}': {}", command, e))?; .map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
let stdin = child.stdin.take() let stdin = child.stdin.take()
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?; .ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
@@ -207,7 +206,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
let deadline = std::time::Instant::now() let deadline = std::time::Instant::now()
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS); + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
let init_result = mcp.call("initialize", json!({ let init_result = mcp.call("initialize", &json!({
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {}, "capabilities": {},
"clientInfo": { "clientInfo": {
@@ -220,9 +219,9 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
anyhow::bail!("MCP initialize timed out"); anyhow::bail!("MCP initialize timed out");
} }
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {}", e))?; init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {e}"))?;
let _ = mcp.call("notifications/initialized", json!({})); let _ = mcp.call("notifications/initialized", &json!({}));
Ok(mcp) Ok(mcp)
} }
@@ -237,23 +236,23 @@ fn call_via_stdio(
// Reuse the persistent child handle if available; otherwise spawn a new one. // Reuse the persistent child handle if available; otherwise spawn a new one.
let mut guard; let mut guard;
let child: &mut StdioChild = if let Some(mtx) = existing_handle { let child: &mut StdioChild = if let Some(mtx) = existing_handle {
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {}", e))?; guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
&mut guard &mut guard
} else { } else {
let mut fresh = spawn_stdio_child(command, extra_args)?; let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call("tools/call", json!({ let result = fresh.call("tools/call", &json!({
"name": tool_name, "name": tool_name,
"arguments": tool_args "arguments": tool_args
}))?; }))?;
return extract_text_content(&result); return Ok(extract_text_content(&result));
}; };
let result = child.call("tools/call", json!({ let result = child.call("tools/call", &json!({
"name": tool_name, "name": tool_name,
"arguments": tool_args "arguments": tool_args
}))?; }))?;
extract_text_content(&result) Ok(extract_text_content(&result))
} }
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> { fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
@@ -294,7 +293,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(&body) .json(&body)
.send() .send()
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {}", e))?; .map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -302,42 +301,42 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
tracing::warn!("[mcp] failed to read HTTP response body: {}", e); tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
String::new() String::new()
}); });
anyhow::bail!("MCP HTTP server returned {}: {}", status, text); anyhow::bail!("MCP HTTP server returned {status}: {text}");
} }
let response: Value = resp.json() let response: Value = resp.json()
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
if let Some(err) = response.get("error") { if let Some(err) = response.get("error") {
anyhow::bail!("MCP HTTP error: {}", err); anyhow::bail!("MCP HTTP error: {err}");
} }
let result = response.get("result").cloned().unwrap_or_else(|| { let result = response.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] HTTP response missing 'result' field"); tracing::warn!("[mcp] HTTP response missing 'result' field");
Value::Null Value::Null
}); });
extract_text_content(&result) Ok(extract_text_content(&result))
} }
fn extract_text_content(result: &Value) -> anyhow::Result<String> { fn extract_text_content(result: &Value) -> String {
if let Some(content) = result.get("content") { if let Some(content) = result.get("content") {
if let Some(arr) = content.as_array() { if let Some(arr) = content.as_array() {
let text: Vec<String> = arr.iter().filter_map(|item| { let text: Vec<String> = arr.iter().filter_map(|item| {
if item.get("type").and_then(|t| t.as_str()) == Some("text") { if item.get("type").and_then(|t| t.as_str()) == Some("text") {
item.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()) item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
} else { } else {
None None
} }
}).collect(); }).collect();
if !text.is_empty() { if !text.is_empty() {
return Ok(text.join("\n")); return text.join("\n");
} }
} }
} }
Ok(serde_json::to_string_pretty(result).unwrap_or_else(|e| { serde_json::to_string_pretty(result).unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to pretty-print result: {}", e); tracing::warn!("[mcp] failed to pretty-print result: {}", e);
result.to_string() result.to_string()
})) })
} }
/// Registry of connected MCP servers and their tools for the current session. /// Registry of connected MCP servers and their tools for the current session.
@@ -374,7 +373,7 @@ impl crate::tool::Tool for McpToolAdapter {
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> { fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
match &self.transport { match &self.transport {
McpTransport::Stdio { command, args: extra_args } => { McpTransport::Stdio { command, args: extra_args } => {
call_via_stdio(self.child_handle.as_ref().map(|h| h.as_ref()), command, extra_args, &self.tool_name, args) call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
} }
McpTransport::StreamableHttp { url } => { McpTransport::StreamableHttp { url } => {
call_via_http(url, &self.tool_name, args) call_via_http(url, &self.tool_name, args)
@@ -428,7 +427,7 @@ impl McpManager {
}; };
let mut child = spawn_stdio_child(command, extra_args)?; let mut child = spawn_stdio_child(command, extra_args)?;
let result = child.call("tools/list", json!({}))?; let result = child.call("tools/list", &json!({}))?;
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) { let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
tool_list.iter().filter_map(|t| { tool_list.iter().filter_map(|t| {
+2 -2
View File
@@ -66,7 +66,7 @@ impl EditorState {
self.cursor_line += 1; self.cursor_line += 1;
} }
self.cursor_col = self.cursor_col.min( self.cursor_col = self.cursor_col.min(
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0), self.content.get(self.cursor_line).map_or(0, std::string::String::len),
); );
} }
@@ -112,7 +112,7 @@ impl EditorState {
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a /// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
/// line and moves down, `\t` inserts two spaces, everything else inserts /// line and moves down, `\t` inserts two spaces, everything else inserts
/// the char directly → mark state dirty. /// the char directly → mark state dirty.
pub fn handle_editor_input(state: &mut AppStateRest, text: String) { pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
let editor = &mut state.misc.editor; let editor = &mut state.misc.editor;
if editor.is_none() { if editor.is_none() {
return; return;
+3 -2
View File
@@ -1,5 +1,6 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Effort mode: cycles the agent's reasoning effort level, which scales the //! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and max_tokens for subsequent turns. //! LLM's temperature and `max_tokens` for subsequent turns.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
@@ -44,7 +45,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
let label = current_effort_str(state); let label = current_effort_str(state);
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info, crate::app::state::types::ToastKind::Info,
format!("Effort: {}", label), format!("Effort: {label}"),
)); ));
state.dirty = true; state.dirty = true;
} }
+12 -15
View File
@@ -1,23 +1,20 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Rewind mode: restores a file to a pre-edit snapshot stored in the //! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's SQLite blob store. //! session's `SQLite` blob store.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use sha2::Digest; use sha2::Digest;
/// Returns the number of stored pre-edit blobs (snapshots) for this session. /// Returns the number of stored pre-edit blobs (snapshots) for this session.
pub fn rewind_count(state: &AppStateRest) -> usize { pub fn rewind_count(state: &AppStateRest) -> usize {
let conn = match open_session_db(&state.session_dir) { let Ok(conn) = open_session_db(&state.session_dir) else { return 0 };
Ok(c) => c,
Err(_) => return 0,
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok() .ok()
.map(|keys| keys.len()) .map_or(0, |keys| keys.len())
.unwrap_or(0)
} }
/// Restores a file to its pre-edit state by retrieving the blob stored under index /// Restores a file to its pre-edit state by retrieving the blob stored under index
/// `index` (0 = oldest). Opens a fresh SQLite connection so this works outside /// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
/// of a running turn (e.g. from the Rewind overlay). /// of a running turn (e.g. from the Rewind overlay).
pub fn rewind_to(state: &mut AppStateRest, index: usize) { pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let conn = match open_session_db(&state.session_dir) { let conn = match open_session_db(&state.session_dir) {
@@ -25,7 +22,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to open session DB: {}", e), format!("Failed to open session DB: {e}"),
)); ));
state.dirty = true; state.dirty = true;
return; return;
@@ -37,7 +34,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to list snapshots: {}", e), format!("Failed to list snapshots: {e}"),
)); ));
state.dirty = true; state.dirty = true;
return; return;
@@ -67,7 +64,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to retrieve snapshot: {}", e), format!("Failed to retrieve snapshot: {e}"),
)); ));
state.dirty = true; state.dirty = true;
return; return;
@@ -81,7 +78,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
.unwrap_or_else(|| state.session_dir.join("snapshot.dat")); .unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
match std::fs::write(&restore_path, &bytes) { match std::fs::write(&restore_path, &bytes) {
Ok(_) => { Ok(()) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success, crate::app::state::types::ToastKind::Success,
format!("Restored {} from snapshot", restore_path.display()), format!("Restored {} from snapshot", restore_path.display()),
@@ -90,7 +87,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to write restored file: {}", e), format!("Failed to write restored file: {e}"),
)); ));
} }
} }
@@ -101,8 +98,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
ts: chrono::Utc::now().timestamp_millis(), ts: chrono::Utc::now().timestamp_millis(),
tool: "rewind".to_string(), tool: "rewind".to_string(),
path: restore_path.to_string_lossy().to_string(), path: restore_path.to_string_lossy().to_string(),
reason: format!("rewind_to({})", index), reason: format!("rewind_to({index})"),
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), content_sha256: hex::encode(sha2::Sha256::digest(&bytes)),
bytes_delta: bytes.len() as i64, bytes_delta: bytes.len() as i64,
origin: crate::app::state::types::Origin::Main.tag(), origin: crate::app::state::types::Origin::Main.tag(),
session_id: state.session_id.clone(), session_id: state.session_id.clone(),
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::model::settings::{Settings, InternetMode};
/// Advance the internet access mode to the next value in the cycle. /// Advance the internet access mode to the next value in the cycle.
/// ///
/// Flow: Off -> ReadOnly -> Full -> Off, wrapping around. /// Flow: Off -> `ReadOnly` -> Full -> Off, wrapping around.
/// ///
/// Why: used by a settings-toggle keybinding to step through modes /// Why: used by a settings-toggle keybinding to step through modes
/// without needing a dropdown/menu. /// without needing a dropdown/menu.
+25 -30
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Adaptive quality-review triggering, build/test probing, staleness //! Adaptive quality-review triggering, build/test probing, staleness
//! sweeps for stored lessons, and the pending-lesson approval workflow. //! sweeps for stored lessons, and the pending-lesson approval workflow.
use std::process::Command; use std::process::Command;
@@ -74,10 +75,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main { if origin != Origin::Main {
return false; return false;
} }
let runtime = match &state.session_runtime { let Some(runtime) = &state.session_runtime else { return false };
Some(r) => r,
None => return false,
};
if !state.settings.review_enabled { if !state.settings.review_enabled {
return false; return false;
} }
@@ -122,8 +120,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
let probe_dir = workspaces.first()?; let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?; let cmd = resolve_verify_command(probe_dir, verify_command)?;
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string())) let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
.unwrap_or_else(|| (cmd.clone(), String::new()));
let Ok(mut child) = Command::new(&cmd_prog) let Ok(mut child) = Command::new(&cmd_prog)
.args(cmd_args.split_whitespace()) .args(cmd_args.split_whitespace())
@@ -143,7 +140,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
let output = child.wait_with_output().ok(); let output = child.wait_with_output().ok();
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default(); let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
return Some(ProbeResult { return Some(ProbeResult {
command: cmd.clone(), command: cmd.clone(),
passed: status.success(), passed: status.success(),
@@ -201,10 +198,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?; let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?; let scripts = v.get("scripts")?;
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
return Some("npm test 2>&1".to_string()); return Some("npm test 2>&1".to_string());
} }
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
return Some("npm run build 2>&1".to_string()); return Some("npm run build 2>&1".to_string());
} }
} }
@@ -306,14 +303,15 @@ fn truncate_output(s: &str, max: usize) -> String {
/// Return: `Ok(())` once the review has been kicked off; errors only /// Return: `Ok(())` once the review has been kicked off; errors only
/// propagate from constructing the subagent context, not from the review /// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead). /// itself (that failure is reported via a `SystemNote` instead).
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { #[allow(clippy::unnecessary_debug_formatting)]
pub fn trigger_review(state: &mut AppStateRest) {
let def = AgentDefinition::new( let def = AgentDefinition::new(
"quality-reviewer".to_string(), "quality-reviewer".to_string(),
"reviewer".to_string(), "reviewer".to_string(),
); );
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = state.session_dir.clone(); ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces = state.workspace_roots.clone(); ctx.workspaces.clone_from(&state.workspace_roots);
let probe_result = probe_build_test( let probe_result = probe_build_test(
&state.workspace_roots, &state.workspace_roots,
state.settings.verify_command.as_deref(), state.settings.verify_command.as_deref(),
@@ -333,21 +331,20 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(), None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
}; };
let session_dir = &state.session_dir;
ctx.system_prompt = format!( ctx.system_prompt = format!(
"You are a code quality reviewer. Review the recent code changes \ "You are a code quality reviewer. Review the recent code changes \
for correctness, and adherence to best practices. \ for correctness, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \ Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \ inspect the session files and provide a concise review verdict. \
Session directory: {:?}\n\n\ Session directory: {session_dir:?}\n\n\
Build/Test Probe:\n{}\n\n\ Build/Test Probe:\n{probe_note}\n\n\
When writing a lesson via remember(), set tags appropriately:\n\ When writing a lesson via remember(), set tags appropriately:\n\
- If build/test verification printed any FAILED/ERROR lines, tag\n\ - If build/test verification printed any FAILED/ERROR lines, tag\n\
the lesson as \"confidence: verified\" (backed by a real failure).\n\ the lesson as \"confidence: verified\" (backed by a real failure).\n\
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\ - If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
(reviewer judgment only).\n\ (reviewer judgment only).\n\
Check for duplicate lessons via recall before writing a new one.", Check for duplicate lessons via recall before writing a new one.",
state.session_dir,
probe_note,
); );
// Use a drain thread for subagent events (so blocking_send never // Use a drain thread for subagent events (so blocking_send never
@@ -359,17 +356,17 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let mut rx = rx; let mut rx = rx;
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[review] tool call: {}", _tool); tracing::debug!("[review] tool call: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[review] tool result: {}", _tool); tracing::debug!("[review] tool result: {}", tool);
} }
SubagentEvent::StepCompleted { _step, .. } => { SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[review] step {} completed", _step); tracing::trace!("[review] step completed");
} }
SubagentEvent::StepFailed { _step, _error } => { SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[review] step {} failed: {}", _step, _error); tracing::warn!("[review] step {} failed: {}", step, error);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[review] completed"); tracing::debug!("[review] completed");
@@ -380,13 +377,13 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let turn_events = state.turn_events.clone(); let turn_events = state.turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match result { let message = match result {
Ok(verdict) => { Ok(verdict) => {
let first_line = verdict.lines().next().unwrap_or(&verdict); let first_line = verdict.lines().next().unwrap_or(&verdict);
format!("Quality review: {}", first_line) format!("Quality review: {first_line}")
} }
Err(e) => format!("Quality review failed: {}", e), Err(e) => format!("Quality review failed: {e}"),
}; };
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
@@ -400,8 +397,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
ToastKind::Info, ToastKind::Info,
"Quality review triggered".to_string(), "Quality review triggered".to_string(),
)); ));
Ok(())
} }
const STALE_AFTER_DAYS: i64 = 60; const STALE_AFTER_DAYS: i64 = 60;
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -69,13 +69,10 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::WorkflowRun { script } => { Command::WorkflowRun { script } => {
vec![Action::RunWorkflow { script }] vec![Action::RunWorkflow { script }]
} }
Command::Pipeline { mode } => {
vec![Action::RunPipeline { mode }]
}
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
message: format!("unknown command: {}", cmd), message: format!("unknown command: {cmd}"),
}] }]
} }
} }
+5 -4
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Short-send / message shaping: compacts long conversation histories so //! Short-send / message shaping: compacts long conversation histories so
//! they fit within the provider's context window before being sent to the //! they fit within the provider's context window before being sent to the
//! LLM API. //! LLM API.
@@ -59,10 +60,10 @@ pub fn shape_messages(
// Always keep the very first message (System Prompt) which we don't count here // Always keep the very first message (System Prompt) which we don't count here
// as we just blindly preserve it later. // as we just blindly preserve it later.
let mut msgs_to_eval = messages.to_vec(); let mut msgs_to_eval = messages.to_vec();
let first = if !msgs_to_eval.is_empty() { let first = if msgs_to_eval.is_empty() {
Some(msgs_to_eval.remove(0))
} else {
None None
} else {
Some(msgs_to_eval.remove(0))
}; };
// Iterate backwards from the most recent to oldest // Iterate backwards from the most recent to oldest
@@ -105,7 +106,7 @@ pub fn shape_messages(
match llm.chat_with_tools_non_streaming(&req_msgs, None) { match llm.chat_with_tools_non_streaming(&req_msgs, None) {
Ok(resp) => { Ok(resp) => {
if let Some(content) = resp.0.content { if let Some(content) = resp.0.content {
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content); summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]");
} }
} }
Err(e) => { Err(e) => {
+21 -30
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into //! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done). //! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn; pub mod turn;
@@ -88,6 +89,7 @@ impl SseParser {
/// provider-specific parsing layer. /// provider-specific parsing layer.
/// ///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame. /// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
#[allow(clippy::too_many_lines)]
fn flush_event(&mut self) -> Vec<StreamEvent> { fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n"); let data = self.data_lines.join("\n");
self.data_lines.clear(); self.data_lines.clear();
@@ -107,15 +109,15 @@ impl SseParser {
}; };
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(|v| v.as_u64()).unwrap_or_else(|| { let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] prompt_tokens missing in usage chunk"); tracing::warn!("[stream] prompt_tokens missing in usage chunk");
0 0
}); });
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| { let completion_tokens = usage.get("completion_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] completion_tokens missing in usage chunk"); tracing::warn!("[stream] completion_tokens missing in usage chunk");
0 0
}); });
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64()) let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64)
.unwrap_or_else(|| { .unwrap_or_else(|| {
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
@@ -126,12 +128,11 @@ impl SseParser {
// in the same chunk; emitting both prevents content loss. // in the same chunk; emitting both prevents content loss.
let has_other_content = value.get("choices") let has_other_content = value.get("choices")
.and_then(|c| c.as_array()) .and_then(|c| c.as_array())
.map(|arr| arr.iter().any(|ch| { .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("content")).is_some()
|| ch.get("delta").and_then(|d| d.get("reasoning_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() || ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
})) }));
.unwrap_or(false);
if !has_other_content { if !has_other_content {
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }]; return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
} }
@@ -139,21 +140,11 @@ impl SseParser {
} }
match event_type.as_str() { match event_type.as_str() {
"message.stop" => vec![StreamEvent::Done], "message.stop" => vec![StreamEvent::Done],
"message.start" => vec![],
"message.delta" | "" => { "message.delta" | "" => {
let delta = match value.get("delta").or_else(|| value.get("choices")) { let Some(delta) = value.get("delta").or_else(|| value.get("choices")) else { return vec![] };
Some(d) => d,
None => return vec![],
};
if let Some(choices) = delta.as_array() { if let Some(choices) = delta.as_array() {
let choice = match choices.first() { let Some(choice) = choices.first() else { return vec![] };
Some(c) => c, let Some(d) = choice.get("delta") else { return vec![] };
None => return vec![],
};
let d = match choice.get("delta") {
Some(v) => v,
None => return vec![],
};
// Content token // Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) { if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
@@ -169,15 +160,15 @@ impl SseParser {
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) { if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
let mut events = Vec::with_capacity(tool_calls.len()); let mut events = Vec::with_capacity(tool_calls.len());
for tc in tool_calls { for tc in tool_calls {
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| { 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"); tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
0 0
}) as usize; }) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
let name = tc.get("function") let name = tc.get("function")
.and_then(|f| f.get("name")) .and_then(|f| f.get("name"))
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.map(|s| s.to_string()); .map(std::string::ToString::to_string);
let args_delta = tc.get("function") let args_delta = tc.get("function")
.and_then(|f| f.get("arguments")) .and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str()) .and_then(|a| a.as_str())
@@ -253,15 +244,15 @@ pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
} }
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
if let Some(tc) = tool_calls.first() { if let Some(tc) = tool_calls.first() {
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| { let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0"); tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0");
0 0
}) as usize; }) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
let name = tc.get("function") let name = tc.get("function")
.and_then(|f| f.get("name")) .and_then(|f| f.get("name"))
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.map(|s| s.to_string()); .map(std::string::ToString::to_string);
let args = tc.get("function") let args = tc.get("function")
.and_then(|f| f.get("arguments")) .and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str()) .and_then(|a| a.as_str())
@@ -289,7 +280,7 @@ mod tests {
assert_eq!(events.len(), 1); assert_eq!(events.len(), 1);
match &events[0] { match &events[0] {
StreamEvent::Token(t) => assert_eq!(t, "hello"), StreamEvent::Token(t) => assert_eq!(t, "hello"),
other => panic!("expected Token, got {:?}", other), other => panic!("expected Token, got {other:?}"),
} }
} }
@@ -302,7 +293,7 @@ mod tests {
assert_eq!(e2.len(), 1); assert_eq!(e2.len(), 1);
match &e2[0] { match &e2[0] {
StreamEvent::Token(t) => assert_eq!(t, "partial"), StreamEvent::Token(t) => assert_eq!(t, "partial"),
other => panic!("expected Token, got {:?}", other), other => panic!("expected Token, got {other:?}"),
} }
} }
@@ -338,7 +329,7 @@ mod tests {
assert_eq!(name.as_deref(), Some("bash")); assert_eq!(name.as_deref(), Some("bash"));
assert_eq!(arguments_delta, "{\"cmd\""); assert_eq!(arguments_delta, "{\"cmd\"");
} }
other => panic!("expected ToolCallDelta, got {:?}", other), other => panic!("expected ToolCallDelta, got {other:?}"),
} }
} }
@@ -355,7 +346,7 @@ mod tests {
assert_eq!(*completion_tokens, 5); assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15); assert_eq!(*total_tokens, 15);
} }
other => panic!("expected Usage, got {:?}", other), other => panic!("expected Usage, got {other:?}"),
} }
} }
@@ -377,7 +368,7 @@ mod tests {
assert_eq!(a, "a"); assert_eq!(a, "a");
assert_eq!(b, "b"); assert_eq!(b, "b");
} }
other => panic!("expected two Tokens, got {:?}", other), other => panic!("expected two Tokens, got {other:?}"),
} }
} }
} }
+2 -2
View File
@@ -84,12 +84,12 @@ impl StreamedTurn {
let tc = &mut self.tool_calls[*index]; let tc = &mut self.tool_calls[*index];
if let Some(new_id) = id { if let Some(new_id) = id {
if !new_id.is_empty() { if !new_id.is_empty() {
tc.id = new_id.clone(); tc.id.clone_from(new_id);
} }
} }
if let Some(new_name) = name { if let Some(new_name) = name {
if !new_name.is_empty() { if !new_name.is_empty() {
tc.name = new_name.clone(); tc.name.clone_from(new_name);
} }
} }
tc.arguments.push_str(arguments_delta); tc.arguments.push_str(arguments_delta);
+5 -17
View File
@@ -90,10 +90,6 @@ const COMMANDS: &[&str] = &[
"/model add", "/model add",
"/workflow", "/workflow",
"/workflow run", "/workflow run",
"/pipeline",
"/pipeline full",
"/pipeline quick",
"/pipeline skip",
"/compact", "/compact",
]; ];
@@ -139,7 +135,7 @@ impl InputState {
self.autocomplete_candidates = COMMANDS self.autocomplete_candidates = COMMANDS
.iter() .iter()
.filter(|c| c.starts_with(&prefix)) .filter(|c| c.starts_with(&prefix))
.map(|c| c.to_string()) .map(std::string::ToString::to_string)
.collect(); .collect();
self.autocomplete_prefix = prefix; self.autocomplete_prefix = prefix;
self.autocomplete_idx = 0; self.autocomplete_idx = 0;
@@ -178,10 +174,10 @@ impl InputState {
pub fn tab_complete(&mut self) { pub fn tab_complete(&mut self) {
// Legacy inline tab-complete — used as a fallback when the dropdown // Legacy inline tab-complete — used as a fallback when the dropdown
// isn't visible yet. Opens the dropdown on the first Tab press. // isn't visible yet. Opens the dropdown on the first Tab press.
if !self.autocomplete_visible { if self.autocomplete_visible {
self.open_autocomplete();
} else {
self.cycle_autocomplete(true); self.cycle_autocomplete(true);
} else {
self.open_autocomplete();
} }
} }
@@ -232,7 +228,7 @@ impl InputState {
.open(path) .open(path)
{ {
use std::io::Write; use std::io::Write;
let _ = writeln!(file, "{}", result); let _ = writeln!(file, "{result}");
} }
} }
} }
@@ -293,13 +289,6 @@ pub struct MiscState {
pub api_context_length: Option<u32>, pub api_context_length: Option<u32>,
pub tick_count: u64, pub tick_count: u64,
pub todo_content: String, pub todo_content: String,
/// Pipeline mode override set by `/pipeline` command.
/// - `None`: auto-detect (default)
/// - `Some("full")`: force full pipeline
/// - `Some("quick")`: force quick pipeline
/// - `Some("skip")`: skip pipeline, handle directly
/// Consumed on the next agent turn.
pub pipeline_override: Option<String>,
} }
impl MiscState { impl MiscState {
@@ -318,7 +307,6 @@ impl MiscState {
api_context_length: None, api_context_length: None,
tick_count: 0, tick_count: 0,
todo_content: String::new(), todo_content: String::new(),
pipeline_override: None,
} }
} }
+17 -25
View File
@@ -84,7 +84,7 @@ impl AppStateRest {
/// Why: falls back to `memory_dir` itself (with a warning) when it has /// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be /// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails. /// read, so construction never fails.
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self { pub fn new(workspace_roots: Vec<PathBuf>, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self {
let settings = Settings::load(); let settings = Settings::load();
let app_config = AppConfig::load(); let app_config = AppConfig::load();
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| { let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
@@ -93,27 +93,25 @@ impl AppStateRest {
}).join("worktrees"); }).join("worktrees");
let dir_cache = DirCache::new(); let dir_cache = DirCache::new();
let session_id = session_dir let session_id = session_dir
.file_name() .file_name().map_or_else(|| {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| {
tracing::warn!("[state] session_dir has no file_name component, using empty session_id"); tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
String::new() String::new()
}); }, |n| n.to_string_lossy().to_string());
let mut state = AppStateRest { let mut state = AppStateRest {
settings, settings,
app_config, app_config,
workspace_roots, workspace_roots,
session_id, session_id,
session_dir: session_dir.clone(), session_dir: session_dir.to_path_buf(),
memory_dir, memory_dir,
worktrees_dir, worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight: Arc::new(Mutex::new(false)), turn_in_flight: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)), dir_cache: Arc::new(RwLock::new(dir_cache)),
edit_log: EditLog::new(&session_dir), edit_log: EditLog::new(session_dir),
session_runtime: Some(SessionRuntime::new(session_dir.clone())), session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: WorkflowEngine::new(), workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(), mcp_manager: McpManager::new(),
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())), lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
@@ -134,10 +132,8 @@ impl AppStateRest {
use sha2::Digest; use sha2::Digest;
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes()); hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = format!("{:x}", hasher.finalize()); let hash_hex = hex::encode(hasher.finalize());
let folder_name = abs_root.file_name() let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "root".to_string());
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
let history_dir = base_dir.join("history"); let history_dir = base_dir.join("history");
let _ = std::fs::create_dir_all(&history_dir); let _ = std::fs::create_dir_all(&history_dir);
@@ -146,7 +142,7 @@ impl AppStateRest {
if let Ok(content) = std::fs::read_to_string(&history_file) { if let Ok(content) = std::fs::read_to_string(&history_file) {
let history: Vec<String> = content let history: Vec<String> = content
.lines() .lines()
.map(|s| s.to_string()) .map(std::string::ToString::to_string)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(); .collect();
state.input.history = history; state.input.history = history;
@@ -192,12 +188,12 @@ impl AppStateRest {
let connected = provisioner::auto_connect(&lsp_mgr, &results); let connected = provisioner::auto_connect(&lsp_mgr, &results);
for name in &connected { for name in &connected {
tracing::info!("LSP: {} connected", name); tracing::info!("LSP: {} connected", name);
let m = format!("LSP: {} connected ✓", name); push_msg(&msg_queue, &m); let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m);
} }
for r in &results { for r in &results {
if let ProvisionResult::Failed { language, server_name, reason, .. } = r { if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
tracing::warn!("LSP {} ({}): {}", server_name, language, reason); tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
let m = format!("LSP: {} ({}) ✗ - {}", server_name, language, reason); push_msg(&msg_queue, &m); let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m);
} }
} }
if connected.is_empty() { if connected.is_empty() {
@@ -216,10 +212,10 @@ impl AppStateRest {
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather /// Return: `false` (and logs a warning) if the mutex is poisoned, rather
/// than propagating a panic. /// than propagating a panic.
pub fn turn_in_flight(&self) -> bool { pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| { self.turn_in_flight.lock().map_or_else(|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned"); tracing::warn!("[state] turn_in_flight mutex poisoned");
false false
}) }, |g| *g)
} }
/// Shut down every running LSP server process. /// Shut down every running LSP server process.
@@ -259,17 +255,13 @@ impl AppStateRest {
/// never fails even on a shallow path. /// never fails even on a shallow path.
pub fn store_base_dir(&self) -> std::path::PathBuf { pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir.parent() self.session_dir.parent()
.and_then(|p| p.parent()) .and_then(|p| p.parent()).map_or_else(|| {
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display()); tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
self.session_dir.parent() self.session_dir.parent().map_or_else(|| {
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display()); tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
self.session_dir.clone() self.session_dir.clone()
}) }, std::path::Path::to_path_buf)
}) }, std::path::Path::to_path_buf)
} }
/// Build a `ToolCtx` for tool calls originating from the main agent. /// Build a `ToolCtx` for tool calls originating from the main agent.
+2 -1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Shared small state types: toasts, overlays, the transcript cache, //! Shared small state types: toasts, overlays, the transcript cache,
//! tool execution model, and call origin tags. //! tool execution model, and call origin tags.
@@ -109,7 +110,7 @@ pub enum Origin {
impl Origin { impl Origin {
/// Short string tag for this origin, used in filenames and logs. /// Short string tag for this origin, used in filenames and logs.
pub fn tag(&self) -> String { pub fn tag(self) -> String {
match self { match self {
Origin::Main => "main".to_string(), Origin::Main => "main".to_string(),
Origin::SubAgent => "subagent".to_string(), Origin::SubAgent => "subagent".to_string(),
+51 -52
View File
@@ -37,12 +37,6 @@ const SKIP_REVIEW_FILES: &[&str] = &[
".gitignore", ".env", ".env.example", ".gitignore", ".env", ".env.example",
]; ];
/// Maximum LLM steps for a quick-review subagent. Keeps reviews fast.
const QUICK_REVIEW_MAX_STEPS: usize = 2;
/// Maximum LLM steps for background subagents (test gen, arch, security).
const BG_SUBAGENT_MAX_STEPS: usize = 8;
/// ─── Helpers ─── /// ─── Helpers ───
/// ///
/// Check whether a file path is worth auto-reviewing (not config/lock/data). /// Check whether a file path is worth auto-reviewing (not config/lock/data).
@@ -72,12 +66,18 @@ fn is_production_code(path: &str) -> bool {
if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") { if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") {
return false; return false;
} }
// Only source files // Only source files — use Path::extension() to avoid clippy
lower.ends_with(".rs") || lower.ends_with(".ts") || lower.ends_with(".tsx") // case_sensitive_file_extension_comparisons lint
|| lower.ends_with(".js") || lower.ends_with(".jsx") || lower.ends_with(".go") std::path::Path::new(&lower)
|| lower.ends_with(".py") || lower.ends_with(".java") || lower.ends_with(".kt") .extension()
|| lower.ends_with(".swift") || lower.ends_with(".c") || lower.ends_with(".cpp") .and_then(|ext| ext.to_str())
|| lower.ends_with(".h") || lower.ends_with(".hpp") .is_some_and(|ext| {
matches!(
ext,
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
| "c" | "cpp" | "h" | "hpp"
)
})
} }
/// ─── Inline Quick Review (synchronous, feeds back to LLM) ─── /// ─── Inline Quick Review (synchronous, feeds back to LLM) ───
@@ -108,10 +108,9 @@ pub fn spawn_quick_review(
"quick-reviewer".to_string(), "quick-reviewer".to_string(),
"reviewer".to_string(), "reviewer".to_string(),
) )
.with_system_prompt(prompt) .with_system_prompt(prompt);
.with_max_steps(QUICK_REVIEW_MAX_STEPS);
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec(); ctx.workspaces = workspaces.to_vec();
@@ -119,11 +118,11 @@ pub fn spawn_quick_review(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[auto-review] tool call: {}", _tool); tracing::debug!("[auto-review] tool call: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[auto-review] tool result: {}", _tool); tracing::debug!("[auto-review] tool result: {}", tool);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[auto-review] completed"); tracing::debug!("[auto-review] completed");
@@ -133,7 +132,7 @@ pub fn spawn_quick_review(
} }
}); });
let verdict = run_subagent(ctx, tx)?; let verdict = run_subagent(&ctx, &tx)?;
tracing::info!( tracing::info!(
"[auto-review] quick review for '{}': {}", "[auto-review] quick review for '{}': {}",
file_path, file_path,
@@ -142,7 +141,7 @@ pub fn spawn_quick_review(
Ok(verdict) Ok(verdict)
} }
/// ─── Background Subagent Spawners (async, report via SystemNote) ─── /// ─── Background Subagent Spawners (async, report via `SystemNote`) ───
/// ///
/// Spawn a background subagent that generates tests for modified files. /// Spawn a background subagent that generates tests for modified files.
/// ///
@@ -183,9 +182,9 @@ pub fn spawn_background_test_gen(
"coder".to_string(), // needs write access "coder".to_string(), // needs write access
) )
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); ;
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = sd; ctx.session_dir = sd;
ctx.workspaces = ws; ctx.workspaces = ws;
@@ -193,17 +192,17 @@ pub fn spawn_background_test_gen(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-test-gen] tool: {}", _tool); tracing::debug!("[bg-test-gen] tool: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-test-gen] result: {}", _tool); tracing::debug!("[bg-test-gen] result: {}", tool);
} }
SubagentEvent::StepCompleted { _step, .. } => { SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[bg-test-gen] step {} done", _step); tracing::trace!("[bg-test-gen] step done");
} }
SubagentEvent::StepFailed { _step, _error } => { SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[bg-test-gen] step {} failed: {}", _step, _error); tracing::warn!("[bg-test-gen] step {} failed: {}", step, error);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-test-gen] completed"); tracing::debug!("[bg-test-gen] completed");
@@ -212,13 +211,13 @@ pub fn spawn_background_test_gen(
} }
}); });
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Auto test-gen: {}", first) format!("Auto test-gen: {first}")
} }
Err(e) => format!("Auto test-gen failed: {}", e), Err(e) => format!("Auto test-gen failed: {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
@@ -263,9 +262,9 @@ pub fn spawn_background_arch_review(
"reviewer".to_string(), "reviewer".to_string(),
) )
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); ;
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = sd; ctx.session_dir = sd;
ctx.workspaces = ws; ctx.workspaces = ws;
@@ -273,11 +272,11 @@ pub fn spawn_background_arch_review(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-arch] tool: {}", _tool); tracing::debug!("[bg-arch] tool: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-arch] result: {}", _tool); tracing::debug!("[bg-arch] result: {}", tool);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-arch] completed"); tracing::debug!("[bg-arch] completed");
@@ -287,13 +286,13 @@ pub fn spawn_background_arch_review(
} }
}); });
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Architecture review: {}", first) format!("Architecture review: {first}")
} }
Err(e) => format!("Architecture review failed: {}", e), Err(e) => format!("Architecture review failed: {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
@@ -349,9 +348,9 @@ pub fn spawn_background_security_review(
"reviewer".to_string(), "reviewer".to_string(),
) )
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); ;
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = sd; ctx.session_dir = sd;
ctx.workspaces = ws; ctx.workspaces = ws;
@@ -359,11 +358,11 @@ pub fn spawn_background_security_review(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-security] tool: {}", _tool); tracing::debug!("[bg-security] tool: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-security] result: {}", _tool); tracing::debug!("[bg-security] result: {}", tool);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-security] completed"); tracing::debug!("[bg-security] completed");
@@ -373,13 +372,13 @@ pub fn spawn_background_security_review(
} }
}); });
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Security review: {}", first) format!("Security review: {first}")
} }
Err(e) => format!("Security review failed: {}", e), Err(e) => format!("Security review failed: {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
+3 -3
View File
@@ -37,15 +37,15 @@ pub struct SubagentContext {
/// ///
/// Return: a context with empty `system_prompt`, empty `workspaces`, /// Return: a context with empty `system_prompt`, empty `workspaces`,
/// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list. /// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list.
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext { pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
if def.role == "reviewer" { if def.role == "reviewer" {
REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect() REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect()
} else { } else {
Vec::new() Vec::new()
} }
}); });
let max_steps = def.max_steps.unwrap_or(25); let max_steps = def.max_steps.unwrap_or(usize::MAX);
SubagentContext { SubagentContext {
system_prompt: String::new(), system_prompt: String::new(),
allowed_tools, allowed_tools,
+6 -6
View File
@@ -16,7 +16,7 @@
use crate::app::subagent::spawn::AgentDefinition; use crate::app::subagent::spawn::AgentDefinition;
/// Division roles — used as both the `role` field in AgentDefinition /// Division roles — used as both the `role` field in `AgentDefinition`
/// and as the key for pipeline routing. /// and as the key for pipeline routing.
pub mod roles { pub mod roles {
/// Strategy Division: plans architecture, creates diagrams, breaks down work. /// Strategy Division: plans architecture, creates diagrams, breaks down work.
@@ -43,7 +43,6 @@ pub fn strategy_division() -> AgentDefinition {
roles::STRATEGY.to_string(), roles::STRATEGY.to_string(),
) )
.with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string()) .with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string())
.with_max_steps(15)
.with_allowed_tools(vec![ .with_allowed_tools(vec![
"read".to_string(), "read".to_string(),
"grep".to_string(), "grep".to_string(),
@@ -57,6 +56,7 @@ pub fn strategy_division() -> AgentDefinition {
"lsp_hover".to_string(), "lsp_hover".to_string(),
"lsp_definition".to_string(), "lsp_definition".to_string(),
"lsp_references".to_string(), "lsp_references".to_string(),
"read_findings".to_string(),
]) ])
} }
@@ -70,7 +70,6 @@ pub fn engineering_division() -> AgentDefinition {
roles::ENGINEERING.to_string(), roles::ENGINEERING.to_string(),
) )
.with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string()) .with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string())
.with_max_steps(50)
.with_allowed_tools(vec![ .with_allowed_tools(vec![
"read".to_string(), "read".to_string(),
"write".to_string(), "write".to_string(),
@@ -90,6 +89,7 @@ pub fn engineering_division() -> AgentDefinition {
"lsp_disconnect".to_string(), "lsp_disconnect".to_string(),
"todowrite".to_string(), "todowrite".to_string(),
"todofinish".to_string(), "todofinish".to_string(),
"read_findings".to_string(),
]) ])
} }
@@ -103,7 +103,6 @@ pub fn quality_division() -> AgentDefinition {
roles::QUALITY.to_string(), roles::QUALITY.to_string(),
) )
.with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string()) .with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string())
.with_max_steps(30)
.with_allowed_tools(vec![ .with_allowed_tools(vec![
"read".to_string(), "read".to_string(),
"write".to_string(), "write".to_string(),
@@ -119,6 +118,7 @@ pub fn quality_division() -> AgentDefinition {
"lsp_hover".to_string(), "lsp_hover".to_string(),
"lsp_definition".to_string(), "lsp_definition".to_string(),
"lsp_references".to_string(), "lsp_references".to_string(),
"read_findings".to_string(),
]) ])
} }
@@ -132,7 +132,6 @@ pub fn security_division() -> AgentDefinition {
roles::SECURITY.to_string(), roles::SECURITY.to_string(),
) )
.with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string()) .with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string())
.with_max_steps(15)
.with_allowed_tools(vec![ .with_allowed_tools(vec![
"read".to_string(), "read".to_string(),
"grep".to_string(), "grep".to_string(),
@@ -146,6 +145,7 @@ pub fn security_division() -> AgentDefinition {
"lsp_hover".to_string(), "lsp_hover".to_string(),
"lsp_definition".to_string(), "lsp_definition".to_string(),
"lsp_references".to_string(), "lsp_references".to_string(),
"read_findings".to_string(),
]) ])
} }
@@ -159,7 +159,6 @@ pub fn documentation_division() -> AgentDefinition {
roles::DOCUMENTATION.to_string(), roles::DOCUMENTATION.to_string(),
) )
.with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string()) .with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string())
.with_max_steps(15)
.with_allowed_tools(vec![ .with_allowed_tools(vec![
"read".to_string(), "read".to_string(),
"write".to_string(), "write".to_string(),
@@ -168,6 +167,7 @@ pub fn documentation_division() -> AgentDefinition {
"glob".to_string(), "glob".to_string(),
"recall".to_string(), "recall".to_string(),
"remember".to_string(), "remember".to_string(),
"read_findings".to_string(),
]) ])
} }
+126 -89
View File
@@ -7,6 +7,7 @@
//! bash exfiltration and destructive-pattern detection) so that subagents //! bash exfiltration and destructive-pattern detection) so that subagents
//! are not a weaker link than the main agent. //! are not a weaker link than the main agent.
use std::fmt::Write;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef; use crate::dto::provider::request::ToolDef;
@@ -27,10 +28,16 @@ use super::event::SubagentEvent;
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) { fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools(); let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() { let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
all all.into_iter()
.filter(|t| t.name() != "company_pipeline" && t.name() != "workflow_run")
.collect()
} else { } else {
all.into_iter() all.into_iter()
.filter(|t| allowed_tools.contains(&t.name().to_string())) .filter(|t| {
allowed_tools.contains(&t.name().to_string())
&& t.name() != "company_pipeline"
&& t.name() != "workflow_run"
})
.collect() .collect()
}; };
let defs = tool_defs(&filtered); let defs = tool_defs(&filtered);
@@ -143,8 +150,7 @@ fn gate_subagent_tool_call(
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or(""); let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason.trim().len() < MIN_REASON_LEN { if reason.trim().len() < MIN_REASON_LEN {
return Some(format!( return Some(format!(
"{} requires a non-trivial 'reason' (>= {} chars) explaining why", "{tool_name} requires a non-trivial 'reason' (>= {MIN_REASON_LEN} chars) explaining why",
tool_name, MIN_REASON_LEN,
)); ));
} }
} }
@@ -157,9 +163,7 @@ fn gate_subagent_tool_call(
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
// For edits, scanning old+new together catches stubs in both // For edits, scanning old+new together catches stubs in both
return if contains_any(old, STUB_PATTERNS) { return if contains_any(old, STUB_PATTERNS) || contains_any(new, STUB_PATTERNS) {
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
} else if contains_any(new, STUB_PATTERNS) {
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string()) Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
} else if contains_any(new, DENIAL_PATTERNS) { } else if contains_any(new, DENIAL_PATTERNS) {
Some("content contains denial/punt pattern; implement properly instead of skipping".to_string()) Some("content contains denial/punt pattern; implement properly instead of skipping".to_string())
@@ -202,13 +206,13 @@ fn gate_subagent_tool_call(
if !is_standard { if !is_standard {
for pat in EXFIL_PATTERNS { for pat in EXFIL_PATTERNS {
if cmd.contains(pat) { if cmd.contains(pat) {
return Some(format!("potential data-exfiltration command blocked (matched '{}')", pat)); return Some(format!("potential data-exfiltration command blocked (matched '{pat}')"));
} }
} }
} }
for pat in SENSITIVE_PATH_PATTERNS { for pat in SENSITIVE_PATH_PATTERNS {
if cmd.contains(pat) { if cmd.contains(pat) {
return Some(format!("refused to read/write sensitive path '{}'", pat)); return Some(format!("refused to read/write sensitive path '{pat}'"));
} }
} }
let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~", let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~",
@@ -216,7 +220,7 @@ fn gate_subagent_tool_call(
"chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "]; "chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "];
for pat in &dangerous { for pat in &dangerous {
if cmd.contains(pat) { if cmd.contains(pat) {
return Some(format!("destructive command pattern blocked: {}", pat)); return Some(format!("destructive command pattern blocked: {pat}"));
} }
} }
if contains_any(cmd, STUB_PATTERNS) { if contains_any(cmd, STUB_PATTERNS) {
@@ -251,7 +255,7 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let mut out = String::new(); let mut out = String::new();
out.push_str("Current Workspace Directory Structure:\n"); out.push_str("Current Workspace Directory Structure:\n");
for root in roots { for root in roots {
out.push_str(&format!("Root: {}\n", root.display())); writeln!(out, "Root: {}", root.display()).unwrap();
let walker = ignore::WalkBuilder::new(root) let walker = ignore::WalkBuilder::new(root)
.hidden(true) .hidden(true)
.git_ignore(true) .git_ignore(true)
@@ -261,9 +265,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let path = entry.path(); let path = entry.path();
if let Ok(rel) = path.strip_prefix(root) { if let Ok(rel) = path.strip_prefix(root) {
if rel.as_os_str().is_empty() { continue; } if rel.as_os_str().is_empty() { continue; }
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let prefix = if is_dir { "[DIR] " } else { " " }; let prefix = if is_dir { "[DIR] " } else { " " };
out.push_str(&format!(" {}{}\n", prefix, rel.display())); writeln!(out, " {}{}", prefix, rel.display()).unwrap();
count += 1; count += 1;
if count > 1000 { if count > 1000 {
out.push_str(" ... (truncated)\n"); out.push_str(" ... (truncated)\n");
@@ -280,10 +284,10 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
/// ///
/// Flow: inject system prompt (with workspace tree if available) → for each /// Flow: inject system prompt (with workspace tree if available) → for each
/// step: resolve provider config, build an LLM client, call /// step: resolve provider config, build an LLM client, call
/// `chat_with_tools_non_streaming`, process tool calls (gated against both /// `chat_with_tools_streaming` (with abort check per SSE event), process
/// the allowlist and Harness-style content safety checks) or collect text /// tool calls (gated against both the allowlist and Harness-style content
/// output → send `SubagentEvent`s on `tx` → break on first text-only /// safety checks) or collect text output → send `SubagentEvent`s on `tx` →
/// (non-empty) response. /// break on first text-only (non-empty) response.
/// ///
/// Why: runs synchronously on a dedicated thread so the main async event /// Why: runs synchronously on a dedicated thread so the main async event
/// loop is not blocked. Tool gating prevents restricted, risky, or /// loop is not blocked. Tool gating prevents restricted, risky, or
@@ -291,7 +295,8 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
/// ///
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM /// Return: the concatenated text output, or an `anyhow::Error` if the LLM
/// call fails at any step. /// call fails at any step.
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> { #[allow(clippy::too_many_lines)]
pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
let mut output = String::new(); let mut output = String::new();
let mut messages: Vec<ChatMessage> = Vec::new(); let mut messages: Vec<ChatMessage> = Vec::new();
@@ -328,22 +333,51 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
// be cancelled from the parent (mirrors main agent behaviour). // be cancelled from the parent (mirrors main agent behaviour).
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
let _ = tx.blocking_send(SubagentEvent::StepFailed { let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step, step,
_error: "subagent aborted by parent".to_string(), error: "subagent aborted by parent".to_string(),
}); });
anyhow::bail!("subagent aborted by parent at step {}", step); anyhow::bail!("subagent aborted by parent at step {step}");
} }
// Use the structured tool-calling API so the LLM can request tools with // Use streaming API so the abort flag is checked per SSE event,
// proper arguments, exactly like the main agent does. // making the subagent responsive to cancellation even during an
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) { // LLM call (non-streaming would block for 10-30s unchecked).
let stream_result = client.chat_with_tools_streaming(
&messages,
tdefs_opt.clone(),
Some(0.7),
Some(4096),
|_event| -> bool {
// Check abort on every SSE event for responsive cancellation.
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
return false; // signals provider to abort
}
// We don't stream tokens to the UI for subagents — just
// need the assembled message at the end.
true
},
);
let (response, _usage) = match stream_result {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|| e.to_string().contains("aborted");
let _ = tx.blocking_send(SubagentEvent::StepFailed { let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step, step,
_error: e.to_string(), error: if is_abort {
"subagent aborted by user".to_string()
} else {
e.to_string()
},
}); });
anyhow::bail!("subagent call failed at step {}: {}", step, e); if is_abort {
anyhow::bail!("subagent aborted by parent at step {step}");
}
// No non-streaming fallback — API must support streaming.
// Non-streaming calls block for up to 1 min without checking
// abort_flag, making cancellation unresponsive.
anyhow::bail!("subagent call failed at step {step}: {e}");
} }
}; };
@@ -357,81 +391,84 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
// Push the assistant message with tool_calls into the conversation // Push the assistant message with tool_calls into the conversation
messages.push(response); messages.push(response);
for tool_call in &tool_calls { let mut results_vec = Vec::new();
// Check abort flag before each tool execution std::thread::scope(|s| {
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { let mut handles = Vec::new();
let _ = tx.blocking_send(SubagentEvent::StepFailed { let tools_ref = &tools;
_step: step, let tool_ctx_ref = &tool_ctx;
_error: "subagent aborted by parent during tool execution".to_string(), for tool_call in &tool_calls {
}); let handle = s.spawn(move || {
anyhow::bail!("subagent aborted by parent during tool call at step {}", step); // Check abort flag before each tool execution
} if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
return (tool_call, Err(anyhow::anyhow!("subagent aborted by parent during tool execution")));
}
let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
// Level 1: allowlist check — is this tool even permitted?
if !generally_allowed {
return (tool_call, Ok(format!("tool '{tool_name}' not allowed for this subagent")));
}
// Level 2: risky tool check — risky tools require explicit permission
if tool_is_risky(tool_name) && !explicitly_allowed {
return (tool_call, Ok(format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent")));
}
// Level 3: Harness-style content safety gating
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
return (tool_call, Ok(format!("Blocked by subagent gate: {block_reason}")));
}
let result = match tools_ref.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => tool.run(tool_ctx_ref, &args),
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
};
(tool_call, result)
});
handles.push(handle);
}
for h in handles {
if let Ok(res) = h.join() {
results_vec.push(res);
}
}
});
for (tool_call, result) in results_vec {
let tool_name = &tool_call.function.name; let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
let _ = tx.blocking_send(SubagentEvent::ToolCall { let _ = tx.blocking_send(SubagentEvent::ToolCall {
_tool: tool_name.clone(), tool: tool_name.clone(),
_args: args.clone(), args: args.clone(),
}); });
// Level 1: allowlist check — is this tool even permitted?
if !generally_allowed {
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: msg,
});
continue;
}
// Level 2: risky tool check — risky tools require explicit permission
if tool_is_risky(tool_name) && !explicitly_allowed {
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: msg,
});
continue;
}
// Level 3: Harness-style content safety gating — mirrors the main
// agent's gate_tool_call checks (path traversal, reason validation,
// stub/denial/assumption scanning, bash exfiltration, destructive
// commands, sensitive path reads).
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
let msg = format!("Blocked by subagent gate: {}", block_reason);
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: msg,
});
continue;
}
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => tool.run(&tool_ctx, &args),
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
};
match result { match result {
Ok(output_text) => { Ok(output_text) => {
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: output_text, output: output_text,
}); });
} }
Err(e) => { Err(e) => {
let msg = format!("tool '{}' failed: {}", tool_name, e); let err_str = e.to_string();
if err_str.contains("subagent aborted by parent") {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
error: err_str.clone(),
});
anyhow::bail!("{err_str}");
}
let msg = format!("tool '{tool_name}' failed: {e}");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: msg, output: msg,
}); });
} }
} }
@@ -443,8 +480,8 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
output.push('\n'); output.push('\n');
} }
let _ = tx.blocking_send(SubagentEvent::StepCompleted { let _ = tx.blocking_send(SubagentEvent::StepCompleted {
_step: step, step,
_output: content.clone(), output: content.clone(),
}); });
// Break only when we got real content; empty means something went wrong // Break only when we got real content; empty means something went wrong
if !content.is_empty() { if !content.is_empty() {
@@ -453,6 +490,6 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
} }
} }
let _ = tx.blocking_send(SubagentEvent::Completed { _output: output.clone() }); let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
Ok(output) Ok(output)
} }
+14 -9
View File
@@ -8,22 +8,27 @@ use serde_json::Value;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SubagentEvent { pub enum SubagentEvent {
StepCompleted { StepCompleted {
_step: usize, #[allow(dead_code)]
_output: String, step: usize,
#[allow(dead_code)]
output: String,
}, },
StepFailed { StepFailed {
_step: usize, step: usize,
_error: String, error: String,
}, },
Completed { Completed {
_output: String, #[allow(dead_code)]
output: String,
}, },
ToolCall { ToolCall {
_tool: String, tool: String,
_args: Value, #[allow(dead_code)]
args: Value,
}, },
ToolResult { ToolResult {
_tool: String, tool: String,
_output: String, #[allow(dead_code)]
output: String,
}, },
} }
+2 -1
View File
@@ -1,4 +1,4 @@
//! AgentDefinition -- declarative specification for instantiating a //! `AgentDefinition` -- declarative specification for instantiating a
//! subagent from workflow scripts or programmatic calls. //! subagent from workflow scripts or programmatic calls.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -30,6 +30,7 @@ impl AgentDefinition {
} }
/// Builder method: limit this agent to at most `steps` LLM calls. /// Builder method: limit this agent to at most `steps` LLM calls.
#[allow(dead_code)]
pub fn with_max_steps(mut self, steps: usize) -> Self { pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps); self.max_steps = Some(steps);
self self
+275 -136
View File
@@ -1,6 +1,6 @@
//! Company-style workflow orchestrator: runs the complete division pipeline //! Company-style workflow orchestrator: runs the complete division pipeline
//! (Strategy → Engineering → Quality Security Documentation) with //! (Strategy → Engineering → [Quality || Security || Documentation] in parallel)
//! findings flowing between stages, then returns a consolidated executive //! with findings flowing between stages, then returns a consolidated executive
//! summary to the CEO (main agent). //! summary to the CEO (main agent).
//! //!
//! Flow: //! Flow:
@@ -9,147 +9,137 @@
//! │ delegates to run_company_pipeline(request) //! │ delegates to run_company_pipeline(request)
//! ▼ //! ▼
//! ┌──────────────────────────────────────────────────┐ //! ┌──────────────────────────────────────────────────┐
//! │ Strategy Division — plan + mermaid diagrams │ //! │ Strategy Division — plan + mermaid diagrams │ (runs sequentially first)
//! │ Engineering Division — implement per plan │ //! └─────────────────────────┬────────────────────────┘
//! Quality Division — review + write tests //!
//! │ Security Division — vulnerability audit │ //! ┌──────────────────────────────────────────────────┐
//! │ Documentation Div — update docs │ //! │ Engineering Division — implement per plan │ (runs sequentially second)
//! └─────────────────────────────────────────────────┘ //! └─────────────────────────────────────────────────┘
//! returns consolidated summary //!
//! //! ┌────────────┼────────────┐
//! CEO Main Agent delivers to user //! ▼ ▼ ▼
//! ┌───────────┐┌───────────┐┌───────────┐
//! │ Quality ││ Security ││ Docs │ (run concurrently in parallel)
//! └───────────┘└───────────┘└───────────┘
//! │ │ │
//! └────────────┼────────────┘
//! ▼
//! CEO Main Agent delivers consolidated summary to user
//! ``` //! ```
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::fmt::Write;
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus}; use std::sync::{Arc, Mutex, atomic::AtomicBool};
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript}; use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
use crate::app::subagent::division; use crate::app::subagent::division;
/// Construct the specialized agents for a division.
///
/// Flow: map division name to its specialization pool.
///
/// Return: a `Vec<ScriptPrimitive>` containing the specialist agents.
fn make_division_specialists(
div: &division::Division,
user_request: &str,
specs: &[(String, String)],
) -> Vec<ScriptPrimitive> {
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
specs
.iter()
.map(|(label, focus)| {
// Prepend [Division Name: Specialist Label] so the first 40 chars
// of the prompt become the agent_name in spawn_single_agent.
// We use quadruple curly braces `{{{{findings}}}}` so that Rust's `format!` formats it
// into `{{findings}}` in the output string, which `resolve_template` then recognizes
// and replaces.
let prompt = format!(
"[{}: {}]\n\n{}\n\n{}\n\nUser request: {}\n\nFindings from previous divisions:\n{{{{findings}}}}",
div.name,
label,
focus,
div_prompt,
user_request,
);
ScriptPrimitive::Agent(prompt)
})
.collect()
}
/// Construct a named Phase wrapper containing a Parallel block of division specialists.
///
/// Flow: construct division specialists → wrap in a `Parallel` primitive wrapper.
///
/// Return: a `ScriptPrimitive::Phase` wrapper.
fn make_division_phase(
div: &division::Division,
user_request: &str,
specs: &[(String, String)],
) -> ScriptPrimitive {
let specialists = make_division_specialists(div, user_request, specs);
ScriptPrimitive::Phase {
name: div.name.to_string(),
script: Box::new(ScriptPrimitive::Parallel(specialists)),
}
}
/// Run the full company-style pipeline for a given user request. /// Run the full company-style pipeline for a given user request.
/// ///
/// This orchestrates all five divisions in sequence: /// This orchestrates all five divisions, running Strategy and Engineering
/// 1. **Strategy** — create plan with diagrams /// sequentially, followed by Quality, Security, and Documentation in parallel.
/// 2. **Engineering** — implement code
/// 3. **Quality** — review + write tests
/// 4. **Security** — audit
/// 5. **Documentation** — update docs
/// ///
/// Each division receives findings from all previous divisions, enabling /// Each division receives findings from all previous divisions, enabling
/// context to flow through the pipeline. /// context to flow through the pipeline.
/// ///
/// Returns a consolidated executive summary string. /// Returns a consolidated executive summary string.
#[allow(clippy::ref_option)]
pub fn run_company_pipeline( pub fn run_company_pipeline(
user_request: &str, user_request: &str,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>, turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
abort_flag: &Option<Arc<AtomicBool>>,
custom_specialists: &HashMap<String, Vec<(String, String)>>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let divisions = division::all_divisions(); let divisions = division::all_divisions();
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(divisions.len());
for div in &divisions { let strategy_specs = custom_specialists.get("Strategy")
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or(""); .ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
// Prepend [Division Name] so the first 40 chars of the prompt let engineering_specs = custom_specialists.get("Engineering")
// become the agent_name in spawn_single_agent, making the TUI .ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
// panel show division names instead of UUID fragments. let quality_specs = custom_specialists.get("Quality")
let prompt = format!( .ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}", let security_specs = custom_specialists.get("Security")
div.name, .ok_or_else(|| anyhow::anyhow!("missing required division configuration: Security"))?;
div_prompt, let documentation_specs = custom_specialists.get("Documentation")
user_request, .ok_or_else(|| anyhow::anyhow!("missing required division configuration: Documentation"))?;
);
pipeline_scripts.push(ScriptPrimitive::Agent(prompt)); let strategy_phase = make_division_phase(&divisions[0], user_request, strategy_specs);
} let engineering_phase = make_division_phase(&divisions[1], user_request, engineering_specs);
let quality_phase = make_division_phase(&divisions[2], user_request, quality_specs);
let security_phase = make_division_phase(&divisions[3], user_request, security_specs);
let documentation_phase = make_division_phase(&divisions[4], user_request, documentation_specs);
let parallel_divisions = ScriptPrimitive::Parallel(vec![
quality_phase,
security_phase,
documentation_phase,
]);
let pipeline_primitive = ScriptPrimitive::Pipeline(vec![
strategy_phase,
engineering_phase,
parallel_divisions,
]);
let wf = WorkflowScript { let wf = WorkflowScript {
name: "company-pipeline".to_string(), name: "company-pipeline".to_string(),
description: format!( description: "Company Pipeline: Strategy → Engineering → (Quality || Security || Documentation)".to_string(),
"Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation", script: pipeline_primitive,
),
script: ScriptPrimitive::Pipeline(pipeline_scripts),
options: ScriptOptions { options: ScriptOptions {
max_concurrency: 1, // sequential by design max_concurrency: 10,
continue_on_error: true, // one division failing shouldn't block the rest
timeout_ms: None,
},
};
// Build a live callback for TUI updates if turn_events is available.
// Uses agent_name (division name) for the display label in the panel.
let live: Option<LiveStateFn> = turn_events.map(|events| {
let events = events.clone();
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
let display_name = agent_name.chars().take(30).collect::<String>();
if let Ok(mut q) = events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: display_name.clone(),
agent_name: display_name,
status,
});
}
});
f
});
let args: HashMap<String, String> = HashMap::new();
let live_ref = live.as_ref();
// Create a per-pipeline findings scope so divisions can pass data
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive(
&wf.script,
&args,
1,
true,
live_ref,
session_dir,
workspaces,
&findings,
None,
)?;
// Collect all findings for the executive summary
let all_findings = findings.lock()
.map(|f| f.clone())
.unwrap_or_default();
Ok(build_executive_summary(user_request, &results, &all_findings, &divisions))
}
/// Run a quick company pipeline that skips non-essential divisions
/// for simple tasks. Flow: Strategy → Engineering → Quality.
///
/// This is for smaller tasks where security audit and full docs are overkill.
pub fn run_company_pipeline_quick(
user_request: &str,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
) -> anyhow::Result<String> {
let divisions = division::all_divisions();
// Only use first 3 divisions for quick pipeline: Strategy, Engineering, Quality
let quick_divisions = &divisions[..3];
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(quick_divisions.len());
for div in quick_divisions {
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
let prompt = format!(
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
div.name,
div_prompt,
user_request,
);
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
}
let wf = WorkflowScript {
name: "company-pipeline-quick".to_string(),
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
script: ScriptPrimitive::Pipeline(pipeline_scripts),
options: ScriptOptions {
max_concurrency: 1,
continue_on_error: true, continue_on_error: true,
timeout_ms: None, timeout_ms: None,
}, },
@@ -174,44 +164,144 @@ pub fn run_company_pipeline_quick(
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive( let results = execute_primitive(
&wf.script, &args, 1, true, &wf.script,
live.as_ref(), session_dir, workspaces, &findings, None, &args,
wf.options.max_concurrency,
true,
abort_flag,
live.as_ref(),
session_dir,
workspaces,
&findings,
None,
)?; )?;
let all_findings = findings.lock() let all_findings = findings.lock()
.map(|f| f.clone()) .map(|f| f.clone())
.unwrap_or_default(); .unwrap_or_default();
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions)) Ok(build_executive_summary(user_request, &results, &all_findings, &divisions, custom_specialists))
}
/// Run a quick company pipeline that skips non-essential divisions
/// for simple tasks. Flow: Strategy → Engineering → Quality.
///
/// This is for smaller tasks where security audit and full docs are overkill.
#[allow(clippy::ref_option)]
pub fn run_company_pipeline_quick(
user_request: &str,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
abort_flag: &Option<Arc<AtomicBool>>,
custom_specialists: &HashMap<String, Vec<(String, String)>>,
) -> anyhow::Result<String> {
let divisions = division::all_divisions();
let quick_divisions = &divisions[..3];
let strategy_specs = custom_specialists.get("Strategy")
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
let engineering_specs = custom_specialists.get("Engineering")
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
let quality_specs = custom_specialists.get("Quality")
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
let strategy_phase = make_division_phase(&quick_divisions[0], user_request, strategy_specs);
let engineering_phase = make_division_phase(&quick_divisions[1], user_request, engineering_specs);
let quality_phase = make_division_phase(&quick_divisions[2], user_request, quality_specs);
let pipeline_primitive = ScriptPrimitive::Pipeline(vec![
strategy_phase,
engineering_phase,
quality_phase,
]);
let wf = WorkflowScript {
name: "company-pipeline-quick".to_string(),
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
script: pipeline_primitive,
options: ScriptOptions {
max_concurrency: 10,
continue_on_error: true,
timeout_ms: None,
},
};
let live: Option<LiveStateFn> = turn_events.map(|events| {
let events = events.clone();
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
let display_name = agent_name.chars().take(30).collect::<String>();
if let Ok(mut q) = events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: display_name.clone(),
agent_name: display_name,
status,
});
}
});
f
});
let args: HashMap<String, String> = HashMap::new();
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive(
&wf.script, &args, wf.options.max_concurrency, true,
abort_flag, live.as_ref(), session_dir, workspaces, &findings, None,
)?;
let all_findings = findings.lock()
.map(|f| f.clone())
.unwrap_or_default();
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions, custom_specialists))
} }
/// Build a compressed executive summary from pipeline results. /// Build a compressed executive summary from pipeline results.
/// ///
/// Keeps output brief to save context window space — just division verdicts /// Flow: print user request header → for each division, fetch its specialist verdicts
/// and key findings, not full outputs. Full results are accessible to the /// → join with pipes → append findings count.
/// CEO via the notes/findings that were archived during execution. ///
/// Why: keeps output brief to save context window space. Full results are accessible
/// to the CEO via findings.
///
/// Return: a formatted executive summary string.
fn build_executive_summary( fn build_executive_summary(
request: &str, request: &str,
results: &[String], results: &[String],
findings: &[String], findings: &[String],
divisions: &[division::Division], divisions: &[division::Division],
custom_specialists: &HashMap<String, Vec<(String, String)>>,
) -> String { ) -> String {
let mut summary = String::new(); let mut summary = String::new();
summary.push_str(&format!("Pipeline for: {}\n", request)); writeln!(summary, "Pipeline for: {request}").unwrap();
for (i, div) in divisions.iter().enumerate() { let mut start_index = 0;
let verdict = results.get(i) for div in divisions {
.map(|r| { let count = custom_specialists.get(div.name)
r.lines().next().unwrap_or(r) .map_or(0, Vec::len);
.chars().take(100).collect::<String>()
})
.unwrap_or_else(|| "".to_string());
summary.push_str(&format!(" {}: {}\n", div.name, verdict)); let mut division_verdicts = Vec::new();
for offset in 0..count {
if let Some(r) = results.get(start_index + offset) {
let first_line = r.lines().next().unwrap_or(r);
let trimmed = first_line.chars().take(40).collect::<String>();
division_verdicts.push(trimmed);
}
}
let verdict = if division_verdicts.is_empty() {
"".to_string()
} else {
division_verdicts.join(" | ")
};
writeln!(summary, " {}: {}", div.name, verdict).unwrap();
start_index += count;
} }
if !findings.is_empty() { if !findings.is_empty() {
summary.push_str(&format!(" Notes: {} cross-division finding(s)\n", findings.len())); writeln!(summary, " Notes: {} cross-division finding(s)", findings.len()).unwrap();
} }
summary summary
@@ -223,7 +313,7 @@ fn build_executive_summary(
/// Simple = single file, minor fix, quick lookup, config change. /// Simple = single file, minor fix, quick lookup, config change.
/// Complex = new feature, multi-file refactor, architecture change. /// Complex = new feature, multi-file refactor, architecture change.
/// ///
/// Used by the auto-CEO pipeline trigger in run_agent_turn to decide /// Used by the auto-CEO pipeline trigger in `run_agent_turn` to decide
/// whether to delegate to the full company pipeline or handle directly. /// whether to delegate to the full company pipeline or handle directly.
/// ///
/// Heuristics: /// Heuristics:
@@ -231,6 +321,7 @@ fn build_executive_summary(
/// - Negative keywords (simple/trivial/typo/quick) skip the pipeline. /// - Negative keywords (simple/trivial/typo/quick) skip the pipeline.
/// - Positive keywords (refactor/api/implement/architecture) trigger it. /// - Positive keywords (refactor/api/implement/architecture) trigger it.
/// - Multi-line or multi-sentence requests are more likely complex. /// - Multi-line or multi-sentence requests are more likely complex.
#[allow(dead_code)]
pub fn is_complex_request(request: &str) -> bool { pub fn is_complex_request(request: &str) -> bool {
let trimmed = request.trim(); let trimmed = request.trim();
// Very short requests are never complex // Very short requests are never complex
@@ -249,7 +340,7 @@ pub fn is_complex_request(request: &str) -> bool {
return false; return false;
} }
// Multi-line/multi-sentence → likely complex // Multi-line/multi-sentence → likely complex
let sentences = trimmed.split(|c| c == '.' || c == '!' || c == '?') let sentences = trimmed.split(['.', '!', '?'])
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.count(); .count();
if sentences >= 3 { if sentences >= 3 {
@@ -265,3 +356,51 @@ pub fn is_complex_request(request: &str) -> bool {
]; ];
complexity_keywords.iter().any(|k| lower.contains(k)) complexity_keywords.iter().any(|k| lower.contains(k))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_complex_request_too_short() {
assert!(!is_complex_request("abc"));
}
#[test]
fn test_is_complex_request_simple_keywords() {
assert!(!is_complex_request("just a simple update to the readme"));
assert!(!is_complex_request("minor typo fix in main.rs"));
}
#[test]
fn test_is_complex_request_multi_sentence() {
assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three."));
}
#[test]
fn test_is_complex_request_complex_keywords() {
assert!(is_complex_request("implement user authentication endpoint"));
assert!(is_complex_request("refactor the whole engine module"));
}
#[test]
fn test_make_division_specialists_custom() {
let divisions = division::all_divisions();
let div = &divisions[0];
let mut custom = HashMap::new();
custom.insert(
"Strategy".to_string(),
vec![
("Custom Label".to_string(), "Custom Focus Description".to_string())
]
);
let specs = make_division_specialists(div, "Test Request", custom.get("Strategy").unwrap());
assert_eq!(specs.len(), 1);
if let ScriptPrimitive::Agent(prompt) = &specs[0] {
assert!(prompt.contains("Custom Label"));
assert!(prompt.contains("Custom Focus Description"));
} else {
panic!("Expected ScriptPrimitive::Agent");
}
}
}
+141 -54
View File
@@ -15,7 +15,7 @@
//! leaks between concurrent workflow runs. //! leaks between concurrent workflow runs.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use std::time::Duration; use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::script::{ScriptPrimitive, WorkflowScript}; use super::script::{ScriptPrimitive, WorkflowScript};
@@ -83,7 +83,7 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// any findings from earlier sibling agents. Updates live state before and /// any findings from earlier sibling agents. Updates live state before and
/// after to reflect Running → Completed/Failed transitions. /// after to reflect Running → Completed/Failed transitions.
/// ///
/// Flow: push agent as `Running` → build SubagentContext with prompt + /// Flow: push agent as `Running` → build `SubagentContext` with prompt +
/// findings preamble, linking the `workflow_findings` Arc so the subagent's /// findings preamble, linking the `workflow_findings` Arc so the subagent's
/// `note_finding` tool pushes into the same vec → call `run_subagent` /// `note_finding` tool pushes into the same vec → call `run_subagent`
/// (draining the event channel into a consumer so events are not blocked) /// (draining the event channel into a consumer so events are not blocked)
@@ -98,12 +98,14 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// a stuck stage from blocking the entire pipeline forever. /// a stuck stage from blocking the entire pipeline forever.
/// ///
/// Return: the agent's text output, or an error on failure. /// Return: the agent's text output, or an error on failure.
#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]
fn spawn_single_agent( fn spawn_single_agent(
agent_id: &str, agent_id: &str,
agent_name: &str, agent_name: &str,
prompt: &str, prompt: &str,
findings_snapshot: Vec<String>, findings_snapshot: &[String],
findings: &Arc<Mutex<Vec<String>>>, findings: &Arc<Mutex<Vec<String>>>,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
@@ -132,9 +134,36 @@ fn spawn_single_agent(
); );
} }
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string()) let mut role = "coder".to_string();
.with_max_steps(50); let mut allowed_tools = None;
let mut ctx = build_subagent_context(def);
if agent_name.contains("Strategy") {
let div_def = crate::app::subagent::division::strategy_division();
role = div_def.role;
allowed_tools = div_def.allowed_tools;
} else if agent_name.contains("Engineering") {
let div_def = crate::app::subagent::division::engineering_division();
role = div_def.role;
allowed_tools = div_def.allowed_tools;
} else if agent_name.contains("Quality") {
let div_def = crate::app::subagent::division::quality_division();
role = div_def.role;
allowed_tools = div_def.allowed_tools;
} else if agent_name.contains("Security") {
let div_def = crate::app::subagent::division::security_division();
role = div_def.role;
allowed_tools = div_def.allowed_tools;
} else if agent_name.contains("Documentation") {
let div_def = crate::app::subagent::division::documentation_division();
role = div_def.role;
allowed_tools = div_def.allowed_tools;
}
let mut def = AgentDefinition::new(agent_name.to_string(), role);
if let Some(tools) = allowed_tools {
def = def.with_allowed_tools(tools);
}
let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec(); ctx.workspaces = workspaces.to_vec();
@@ -152,13 +181,11 @@ fn spawn_single_agent(
) )
}; };
ctx.system_prompt = format!("{}{}", prompt, findings_section); ctx.system_prompt = format!("{prompt}{findings_section}");
// Link the shared findings Arc so note_finding calls within this // Link the shared findings Arc so note_finding calls within this
// subagent write into the same vec visible to sibling agents. // subagent write into the same vec visible to sibling agents.
ctx.workflow_findings = Some(findings.clone()); ctx.workflow_findings = Some(findings.clone());
// Abort flag stays None by default — the parent can set it to abort ctx.abort_flag.clone_from(abort_flag);
// long-running agents. No abort mechanism is wired yet at this level;
// future work can expose a kill-switch per agent via the live callback.
// Create an mpsc channel and drain events in a background thread. // Create an mpsc channel and drain events in a background thread.
// The drain thread also pushes intra-division progress updates to the // The drain thread also pushes intra-division progress updates to the
@@ -174,8 +201,8 @@ fn spawn_single_agent(
let mut rx = rx; let mut rx = rx;
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, _args } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[subagent] tool call: {}", _tool); tracing::debug!("[subagent] tool call: {}", tool);
// Push intra-division progress: which tool is running // Push intra-division progress: which tool is running
if let Some(ref f) = drain_live { if let Some(ref f) = drain_live {
f( f(
@@ -186,13 +213,13 @@ fn spawn_single_agent(
started_at: Some(drain_started_at), started_at: Some(drain_started_at),
completed_at: None, completed_at: None,
error: None, error: None,
progress: Some(format!("tool: {}", _tool)), progress: Some(format!("tool: {tool}")),
}, },
); );
} }
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[subagent] tool result: {}", _tool); tracing::debug!("[subagent] tool result: {}", tool);
if let Some(ref f) = drain_live { if let Some(ref f) = drain_live {
f( f(
drain_agent_id.clone(), drain_agent_id.clone(),
@@ -202,16 +229,16 @@ fn spawn_single_agent(
started_at: Some(drain_started_at), started_at: Some(drain_started_at),
completed_at: None, completed_at: None,
error: None, error: None,
progress: Some(format!("done: {}", _tool)), progress: Some(format!("done: {tool}")),
}, },
); );
} }
} }
SubagentEvent::StepCompleted { _step, .. } => { SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[subagent] step {} completed", _step); tracing::trace!("[subagent] step completed");
} }
SubagentEvent::StepFailed { _step, _error } => { SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[subagent] step {} failed: {}", _step, _error); tracing::warn!("[subagent] step {} failed: {}", step, error);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[subagent] completed"); tracing::debug!("[subagent] completed");
@@ -220,28 +247,55 @@ fn spawn_single_agent(
} }
}); });
// Enforce timeout by running subagent on a separate thread and // Check abort before even starting the subagent.
// waiting with a deadline. If the deadline expires, the thread is if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
// abandoned (Rust threads cannot be forcibly killed, but we proceed anyhow::bail!("subagent '{agent_name}' aborted before start");
// without waiting for it — the drain thread will drop when tx is }
// dropped on thread exit).
let result = if let Some(timeout) = timeout_ms { // Run subagent on a separate thread so the abort flag can be polled.
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>(); // If abort is requested while the subagent is running, we abandon the
let timeout_ctx = ctx; // thread (Rust threads cannot be forcibly killed) and return early.
let timeout_tx = tx; let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
std::thread::spawn(move || { let bg_ctx = ctx;
let _ = done_tx.send(run_subagent(timeout_ctx, timeout_tx)); let bg_tx = tx;
}); let bg_name = agent_name.to_string();
match done_rx.recv_timeout(Duration::from_millis(timeout)) { let bg_abort = abort_flag.clone();
Ok(r) => r, std::thread::spawn(move || {
Err(_) => Err(anyhow::anyhow!( let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
"subagent '{}' timed out after {}ms", });
agent_name, timeout,
)), let poll_interval = Duration::from_millis(200);
let result = if let Some(timeout) = timeout_ms {
let deadline = Duration::from_millis(timeout);
let mut elapsed = Duration::ZERO;
loop {
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
break r;
} }
} else { elapsed += poll_interval;
run_subagent(ctx, tx) if elapsed >= deadline {
}; break Err(anyhow::anyhow!(
"subagent '{bg_name}' timed out after {timeout}ms",
));
}
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
break Err(anyhow::anyhow!(
"subagent '{bg_name}' aborted by user",
));
}
}
} else {
loop {
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
break r;
}
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
break Err(anyhow::anyhow!(
"subagent '{bg_name}' aborted by user",
));
}
}
};
let completed_at = chrono::Utc::now().timestamp_millis(); let completed_at = chrono::Utc::now().timestamp_millis();
@@ -299,11 +353,14 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// ///
/// Return: a `Vec<String>` of all agent outputs (or error strings) in /// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted. /// the order they were submitted.
#[allow(clippy::too_many_arguments)]
#[allow(clippy::ref_option, clippy::too_many_lines)]
pub fn execute_primitive( pub fn execute_primitive(
primitive: &ScriptPrimitive, primitive: &ScriptPrimitive,
args: &HashMap<String, String>, args: &HashMap<String, String>,
concurrency_cap: usize, concurrency_cap: usize,
continue_on_error: bool, continue_on_error: bool,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
@@ -312,11 +369,25 @@ pub fn execute_primitive(
) -> anyhow::Result<Vec<String>> { ) -> anyhow::Result<Vec<String>> {
match primitive { match primitive {
ScriptPrimitive::Agent(prompt) => { ScriptPrimitive::Agent(prompt) => {
let resolved = resolve_template(prompt, args); let mut resolved_args = args.clone();
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default(); let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
let formatted_findings = if findings_snapshot.is_empty() {
"None".to_string()
} else {
findings_snapshot
.iter()
.enumerate()
.map(|(i, f)| format!("{}. {}", i + 1, f))
.collect::<Vec<_>>()
.join("\n")
};
resolved_args.insert("findings".to_string(), formatted_findings);
}
let resolved = resolve_template(prompt, &resolved_args);
let agent_id = uuid::Uuid::new_v4().to_string(); let agent_id = uuid::Uuid::new_v4().to_string();
let agent_name = resolved.chars().take(40).collect::<String>(); let agent_name = resolved.chars().take(40).collect::<String>();
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) { match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
Ok(text) => Ok(vec![text]), Ok(text) => Ok(vec![text]),
Err(e) => { Err(e) => {
if continue_on_error { if continue_on_error {
@@ -347,6 +418,7 @@ pub fn execute_primitive(
let sem = Arc::clone(&semaphore); let sem = Arc::clone(&semaphore);
let results = Arc::clone(&results); let results = Arc::clone(&results);
let cap = concurrency_cap; let cap = concurrency_cap;
let abort = abort_flag.clone();
let live_clone = live.cloned(); let live_clone = live.cloned();
let session_dir = session_dir.to_path_buf(); let session_dir = session_dir.to_path_buf();
let workspaces = workspaces.to_vec(); let workspaces = workspaces.to_vec();
@@ -357,6 +429,7 @@ pub fn execute_primitive(
let _permit = sem.acquire(); let _permit = sem.acquire();
let result = execute_primitive( let result = execute_primitive(
&script, &args, cap, continue_on_error, &script, &args, cap, continue_on_error,
&abort,
live_clone.as_ref(), live_clone.as_ref(),
&session_dir, &session_dir,
&workspaces, &workspaces,
@@ -380,7 +453,7 @@ pub fn execute_primitive(
for (_, res) in locked.drain(..) { for (_, res) in locked.drain(..) {
match res { match res {
Ok(outputs) => all.extend(outputs), Ok(outputs) => all.extend(outputs),
Err(e) => all.push(format!("agent error: {}", e)), Err(e) => all.push(format!("agent error: {e}")),
} }
} }
Ok(all) Ok(all)
@@ -389,17 +462,30 @@ pub fn execute_primitive(
ScriptPrimitive::Pipeline(scripts) => { ScriptPrimitive::Pipeline(scripts) => {
// Sequential: each stage runs only after the previous completes. // Sequential: each stage runs only after the previous completes.
// //
// Abort is checked between stages so the user can cancel the
// pipeline immediately when moving to the next division, rather
// than having to wait for the current subagent to finish.
//
// Why: parallel execution defeats the purpose of a pipeline whose // Why: parallel execution defeats the purpose of a pipeline whose
// stages are supposed to build on each other's output. Findings // stages are supposed to build on each other's output. Findings
// written by stage N are visible to stage N+1 through the shared // written by stage N are visible to stage N+1 through the shared
// `findings` Arc (same isolation scope as parent). // `findings` Arc (same isolation scope as parent).
let mut all = Vec::new(); let mut all = Vec::new();
for (idx, script) in scripts.iter().enumerate() { for (idx, script) in scripts.iter().enumerate() {
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) { // Check abort before each pipeline stage so we don't
// launch the next division after the user cancelled.
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
if continue_on_error {
all.push(format!("pipeline aborted at stage {idx}"));
break;
}
anyhow::bail!("pipeline aborted by user at stage {idx}");
}
match execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms) {
Ok(outputs) => all.extend(outputs), Ok(outputs) => all.extend(outputs),
Err(e) => { Err(e) => {
if continue_on_error { if continue_on_error {
all.push(format!("pipeline stage {} error: {}", idx, e)); all.push(format!("pipeline stage {idx} error: {e}"));
} else { } else {
return Err(e); return Err(e);
} }
@@ -410,7 +496,7 @@ pub fn execute_primitive(
} }
ScriptPrimitive::Phase { name: _name, script } => { ScriptPrimitive::Phase { name: _name, script } => {
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms)
} }
} }
} }
@@ -425,7 +511,7 @@ pub fn run_workflow(
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
run_workflow_tracked(script, args, None, session_dir, workspaces) run_workflow_tracked(script, args, &None, None, session_dir, workspaces)
} }
/// Run a `WorkflowScript` with real-time live-state callbacks so the TUI /// Run a `WorkflowScript` with real-time live-state callbacks so the TUI
@@ -437,13 +523,15 @@ pub fn run_workflow(
/// ///
/// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a /// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a
/// global static, so concurrent `run_workflow_tracked` calls from different /// global static, so concurrent `run_workflow_tracked` calls from different
/// spawn_agents invocations remain fully isolated. /// `spawn_agents` invocations remain fully isolated.
/// ///
/// Return: a human-readable summary string. /// Return: a human-readable summary string.
#[allow(clippy::ref_option)]
pub fn run_workflow_tracked( pub fn run_workflow_tracked(
script: &WorkflowScript, script: &WorkflowScript,
args: &HashMap<String, String>, args: &HashMap<String, String>,
live: Option<LiveStateFn>, abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
@@ -453,11 +541,10 @@ pub fn run_workflow_tracked(
10 10
}; };
let live_ref = live.as_ref();
let findings = Arc::new(Mutex::new(Vec::new())); let findings = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive( let results = execute_primitive(
&script.script, args, concurrency_cap, &script.script, args, concurrency_cap,
script.options.continue_on_error, live_ref, script.options.continue_on_error, abort_flag, live,
session_dir, workspaces, &findings, session_dir, workspaces, &findings,
script.options.timeout_ms, script.options.timeout_ms,
)?; )?;
@@ -489,7 +576,7 @@ pub fn run_workflow_tracked(
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String { fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
let mut result = template.to_string(); let mut result = template.to_string();
for (key, value) in args { for (key, value) in args {
result = result.replace(&format!("{{{{{}}}}}", key), value); result = result.replace(&format!("{{{{{key}}}}}"), value);
} }
result result
} }
@@ -535,7 +622,7 @@ struct SemaphoreGuard<'a> {
sem: &'a Semaphore, sem: &'a Semaphore,
} }
impl<'a> Drop for SemaphoreGuard<'a> { impl Drop for SemaphoreGuard<'_> {
fn drop(&mut self) { fn drop(&mut self) {
let mut count = self.sem.count.lock().unwrap_or_else(|e| { let mut count = self.sem.count.lock().unwrap_or_else(|e| {
tracing::warn!("[semaphore] mutex poisoned in drop, recovering"); tracing::warn!("[semaphore] mutex poisoned in drop, recovering");
-13
View File
@@ -22,10 +22,6 @@ pub enum Command {
WorkflowRun { WorkflowRun {
script: String, script: String,
}, },
/// /pipeline full|quick|skip
Pipeline {
mode: String,
},
Unknown(String), Unknown(String),
} }
@@ -79,15 +75,6 @@ pub fn parse_command(text: &str) -> Command {
"/workflow" => Command::WorkflowRun { "/workflow" => Command::WorkflowRun {
script: arg1.to_string(), script: arg1.to_string(),
}, },
"/pipeline" if arg1.is_empty() => Command::Pipeline {
mode: "status".to_string(),
},
"/pipeline" if arg1 == "full" || arg1 == "quick" || arg1 == "skip" => {
Command::Pipeline {
mode: arg1.to_string(),
}
}
"/pipeline" => Command::Unknown(format!("/pipeline {} (use: full|quick|skip)", arg1)),
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
} }
} }
+11 -20
View File
@@ -21,6 +21,7 @@ use crate::controller::command::parse_command;
/// Why: when Editor overlay is active, all key events are consumed by the /// Why: when Editor overlay is active, all key events are consumed by the
/// editor handler and never reach the main action dispatch. Return `Vec` /// editor handler and never reach the main action dispatch. Return `Vec`
/// so that a single key press can trigger multiple actions. /// so that a single key press can trigger multiple actions.
#[allow(clippy::too_many_lines)]
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> { pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// While Editor overlay is active, route input directly to the editor handler // While Editor overlay is active, route input directly to the editor handler
if state.misc.overlay == Overlay::Editor { if state.misc.overlay == Overlay::Editor {
@@ -34,7 +35,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
if let Err(e) = std::fs::write(&ed.path, &content) { if let Err(e) = std::fs::write(&ed.path, &content) {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Save failed: {}", e), format!("Save failed: {e}"),
)); ));
} else { } else {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
@@ -58,11 +59,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
return vec![]; return vec![];
} }
KeyCode::Enter => { KeyCode::Enter => {
crate::app::mode::editor::handle_editor_input(state, "\n".to_string()); crate::app::mode::editor::handle_editor_input(state, "\n");
return vec![]; return vec![];
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
crate::app::mode::editor::handle_editor_input(state, c.to_string()); crate::app::mode::editor::handle_editor_input(state, &c.to_string());
return vec![]; return vec![];
} }
_ => return vec![], _ => return vec![],
@@ -93,25 +94,15 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
} }
KeyCode::Enter | KeyCode::Char('a') => { KeyCode::Enter | KeyCode::Char('a') => {
let items = crate::app::mode::learning::get_learning_items(state); let items = crate::app::mode::learning::get_learning_items(state);
if let Some(item) = items.get(state.misc.selected_index) { if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
match item { return vec![Action::LessonAccept { name: name.clone() }];
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
return vec![Action::LessonAccept { name: name.clone() }];
}
_ => {}
}
} }
return vec![]; return vec![];
} }
KeyCode::Char('r') => { KeyCode::Char('r') => {
let items = crate::app::mode::learning::get_learning_items(state); let items = crate::app::mode::learning::get_learning_items(state);
if let Some(item) = items.get(state.misc.selected_index) { if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
match item { return vec![Action::LessonReject { name: name.clone() }];
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
return vec![Action::LessonReject { name: name.clone() }];
}
_ => {}
}
} }
return vec![]; return vec![];
} }
@@ -341,8 +332,8 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider); tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider);
"claude-opus-4-8".to_string() "claude-opus-4-8".to_string()
}); });
state.settings.provider = provider.clone(); state.settings.provider.clone_from(provider);
state.settings.model = model.clone(); state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key { if let Some(ref key) = cfg.default_api_key {
state.settings.api_keys.insert(provider.clone(), key.clone()); state.settings.api_keys.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) { } else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) {
@@ -351,7 +342,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
let _ = state.settings.save(); let _ = state.settings.save();
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success, crate::app::state::types::ToastKind::Success,
format!("Switched to {} / {}", provider, model), format!("Switched to {provider} / {model}"),
)); ));
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@ use serde_json::Value;
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider. /// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider.
/// ///
/// Flow: constructed from the current message history plus optional /// Flow: constructed from the current message history plus optional
/// generation knobs (temperature, max_tokens, tools, etc.) and serialized /// generation knobs (temperature, `max_tokens`, tools, etc.) and serialized
/// directly into the HTTP request body. /// directly into the HTTP request body.
/// ///
/// Return: not a function, but the value that becomes the JSON request /// Return: not a function, but the value that becomes the JSON request
+2 -2
View File
@@ -19,8 +19,8 @@ pub struct Connection {
impl Connection { impl Connection {
/// Wrap an already-connected/accepted `UnixStream`. /// Wrap an already-connected/accepted `UnixStream`.
pub fn from_stream(stream: UnixStream) -> Result<Self> { pub fn from_stream(stream: UnixStream) -> Self {
Ok(Connection { inner: stream }) Connection { inner: stream }
} }
/// Open a new Unix-socket connection to `path`. /// Open a new Unix-socket connection to `path`.
+123 -2
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Length-prefixed binary framing and JSON (de)serialization helpers for //! Length-prefixed binary framing and JSON (de)serialization helpers for
//! the IPC wire protocol. //! the IPC wire protocol.
//! //!
@@ -26,7 +27,7 @@ pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> { pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
let len = data.len(); let len = data.len();
if len > MAX_FRAME_SIZE { if len > MAX_FRAME_SIZE {
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len); anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit");
} }
let len_bytes = (len as u32).to_be_bytes(); let len_bytes = (len as u32).to_be_bytes();
writer.write_all(&len_bytes)?; writer.write_all(&len_bytes)?;
@@ -52,7 +53,7 @@ pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
} }
let len = u32::from_be_bytes(len_buf) as usize; let len = u32::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_SIZE { if len > MAX_FRAME_SIZE {
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len); anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit");
} }
let mut buf = vec![0u8; len]; let mut buf = vec![0u8; len];
reader.read_exact(&mut buf)?; reader.read_exact(&mut buf)?;
@@ -72,3 +73,123 @@ pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> { pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
Ok(serde_json::from_slice(data)?) Ok(serde_json::from_slice(data)?)
} }
#[cfg(test)]
mod tests {
use super::*;
/// Write a value, read it back, and verify exact equality.
fn roundtrip_bytes(data: &[u8]) {
let mut buf: Vec<u8> = Vec::new();
write_frame(&mut buf, data).unwrap();
let read_back = read_frame(&mut buf.as_slice())
.unwrap()
.expect("expected Some(frame)");
assert_eq!(read_back, data);
}
#[test]
fn test_write_read_roundtrip_empty() {
roundtrip_bytes(b"");
}
#[test]
fn test_write_read_roundtrip_small_text() {
roundtrip_bytes(b"hello world");
}
#[test]
fn test_write_read_roundtrip_binary() {
roundtrip_bytes(&[0x00, 0xFF, 0xAB, 0xCD, 0x01, 0x02, 0x03]);
}
#[test]
fn test_write_read_roundtrip_large() {
let data = vec![0x42u8; 100_000];
roundtrip_bytes(&data);
}
#[test]
fn test_write_rejects_too_large_frame() {
let oversized = vec![0u8; MAX_FRAME_SIZE + 1];
let mut buf = Vec::new();
let result = write_frame(&mut buf, &oversized);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("too large") || err.contains("64 MiB"));
}
#[test]
fn test_read_rejects_too_large_header() {
// Manually craft a 4-byte length header that exceeds MAX_FRAME_SIZE
let len = (MAX_FRAME_SIZE as u32).wrapping_add(1);
let header = len.to_be_bytes();
let mut buf = Vec::from(&header[..]);
buf.extend_from_slice(b"dummy");
let result = read_frame(&mut buf.as_slice());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("too large"));
}
#[test]
fn test_read_empty_buf_returns_none() {
let empty: &[u8] = &[];
let result = read_frame(&mut &empty[..]).unwrap();
assert!(result.is_none(), "expected None for empty reader");
}
#[test]
fn test_read_partial_header_returns_none() {
// Only 2 bytes of the 4-byte header → EOF
let partial: &[u8] = &[0x00, 0x01];
let result = read_frame(&mut &partial[..]).unwrap();
assert!(result.is_none(), "expected None for partial header");
}
#[test]
fn test_read_truncated_payload_returns_err() {
let mut buf = Vec::new();
let header = (10u32).to_be_bytes();
buf.extend_from_slice(&header);
buf.extend_from_slice(b"abc"); // only 3 of 10 bytes
let result = read_frame(&mut buf.as_slice());
assert!(result.is_err(), "truncated payload should error");
}
#[test]
fn test_serialize_deserialize_roundtrip() {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct Msg {
id: u32,
content: String,
tags: Vec<String>,
}
let original = Msg {
id: 42,
content: "hello world".into(),
tags: vec!["foo".into(), "bar".into()],
};
let bytes = serialize_frame(&original).unwrap();
let deserialized: Msg = deserialize_frame(&bytes).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_serialize_rejects_oversized_value() {
let huge = vec![0u8; MAX_FRAME_SIZE + 1];
let result = serialize_frame(&huge);
assert!(result.is_err());
}
#[test]
fn test_deserialize_malformed_json_errors() {
let bad_json = b"this is not json";
let result: Result<String> = deserialize_frame(bad_json);
assert!(result.is_err());
}
}
+1 -1
View File
@@ -30,6 +30,6 @@ impl IpcServer {
/// Block until a client connects, then wrap it as a `Connection`. /// Block until a client connects, then wrap it as a `Connection`.
pub fn accept(&self) -> Result<Connection> { pub fn accept(&self) -> Result<Connection> {
let (stream, _addr) = self.listener.accept()?; let (stream, _addr) = self.listener.accept()?;
Connection::from_stream(stream) Ok(Connection::from_stream(stream))
} }
} }
+14 -20
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Zesdex binary entry point. //! Zesdex binary entry point.
//! //!
//! Parses `--daemon` / `--attach <id>` flags to select one of three //! Parses `--daemon` / `--attach <id>` flags to select one of three
@@ -11,7 +12,6 @@ use std::sync::Mutex;
use anyhow::Result; use anyhow::Result;
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
@@ -106,7 +106,7 @@ fn run_single_process() -> Result<()> {
let workspace_roots = vec![std::env::current_dir()?]; let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new( let mut state = app::state::rest::AppStateRest::new(
workspace_roots.clone(), workspace_roots.clone(),
session_dir, &session_dir,
store.memory_dir, store.memory_dir,
); );
state.sessions = model::session::Session::list(&store.base_dir); state.sessions = model::session::Session::list(&store.base_dir);
@@ -117,7 +117,7 @@ fn run_single_process() -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen)?;
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()?;
@@ -125,11 +125,11 @@ fn run_single_process() -> Result<()> {
let run_result = run_loop(&mut state, &mut terminal); let run_result = run_loop(&mut state, &mut terminal);
let mut restore_stdout = io::stdout(); let mut restore_stdout = io::stdout();
let _ = execute!(restore_stdout, LeaveAlternateScreen, DisableMouseCapture); let _ = execute!(restore_stdout, LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
if let Err(e) = run_result { if let Err(e) = run_result {
let _ = writeln!(restore_stdout, "error: {}", e); let _ = writeln!(restore_stdout, "error: {e}");
let _ = restore_stdout.flush(); let _ = restore_stdout.flush();
} }
@@ -262,7 +262,6 @@ fn apply_client_update(
state.transcript_cache.messages = payload.messages.into_iter().map(|m| { state.transcript_cache.messages = payload.messages.into_iter().map(|m| {
app::state::rest::ChatMessageDisplay { app::state::rest::ChatMessageDisplay {
role: match m.role.as_str() { role: match m.role.as_str() {
"User" => crate::dto::chat::message::Role::User,
"Assistant" => crate::dto::chat::message::Role::Assistant, "Assistant" => crate::dto::chat::message::Role::Assistant,
"System" => crate::dto::chat::message::Role::System, "System" => crate::dto::chat::message::Role::System,
"Tool" => crate::dto::chat::message::Role::Tool, "Tool" => crate::dto::chat::message::Role::Tool,
@@ -300,7 +299,6 @@ fn apply_client_update(
state.misc.toasts = payload.toasts.into_iter().map(|t| { state.misc.toasts = payload.toasts.into_iter().map(|t| {
Toast { Toast {
kind: match t.kind.as_str() { kind: match t.kind.as_str() {
"Info" => ToastKind::Info,
"Success" => ToastKind::Success, "Success" => ToastKind::Success,
"Warning" => ToastKind::Warning, "Warning" => ToastKind::Warning,
"Error" => ToastKind::Error, "Error" => ToastKind::Error,
@@ -350,7 +348,7 @@ fn run_daemon() -> Result<()> {
let workspace_roots = vec![std::env::current_dir()?]; let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new( let mut state = app::state::rest::AppStateRest::new(
workspace_roots.clone(), workspace_roots.clone(),
session_dir, &session_dir,
store.memory_dir, store.memory_dir,
); );
state.sessions = model::session::Session::list(&store.base_dir); state.sessions = model::session::Session::list(&store.base_dir);
@@ -359,17 +357,17 @@ fn run_daemon() -> Result<()> {
let run_dir = store.base_dir.join("run"); let run_dir = store.base_dir.join("run");
std::fs::create_dir_all(&run_dir)?; std::fs::create_dir_all(&run_dir)?;
let socket_path = run_dir.join(format!("{}.sock", session_id)); let socket_path = run_dir.join(format!("{session_id}.sock"));
let addr = socket_path.to_string_lossy().to_string(); let addr = socket_path.to_string_lossy().to_string();
let server = ipc::server::IpcServer::bind_unix(&addr)?; let server = ipc::server::IpcServer::bind_unix(&addr)?;
eprintln!("daemon: listening on {}", addr); eprintln!("daemon: listening on {addr}");
loop { loop {
let mut conn = match server.accept() { let mut conn = match server.accept() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
eprintln!("daemon: accept error: {}", e); eprintln!("daemon: accept error: {e}");
break; break;
} }
}; };
@@ -463,13 +461,13 @@ fn run_attach(session_id: &str) -> Result<()> {
let store = model::store::Store::new(); let store = model::store::Store::new();
let socket_path = store.base_dir.join("run").join(format!("{}.sock", session_id)); let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
let addr = socket_path.to_string_lossy().to_string(); let addr = socket_path.to_string_lossy().to_string();
let mut client = ipc::client::IpcClient::connect_unix(&addr)?; let mut client = ipc::client::IpcClient::connect_unix(&addr)?;
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen)?;
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()?;
@@ -479,7 +477,7 @@ fn run_attach(session_id: &str) -> Result<()> {
std::fs::create_dir_all(&session_dir)?; std::fs::create_dir_all(&session_dir)?;
let mut client_state = app::state::rest::AppStateRest::new( let mut client_state = app::state::rest::AppStateRest::new(
workspace_roots, workspace_roots,
session_dir, &session_dir,
store.memory_dir, store.memory_dir,
); );
client_state.session_id = session_id.to_string(); client_state.session_id = session_id.to_string();
@@ -547,10 +545,7 @@ fn run_attach(session_id: &str) -> Result<()> {
), ),
); );
} }
Some(ipc::protocol::DaemonFrame::Closed) => { Some(ipc::protocol::DaemonFrame::Closed) | None => {
client_state.quit = true;
}
None => {
client_state.quit = true; client_state.quit = true;
} }
} }
@@ -560,7 +555,7 @@ fn run_attach(session_id: &str) -> Result<()> {
})?; })?;
} }
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = client_state.settings.save(); let _ = client_state.settings.save();
@@ -584,7 +579,6 @@ fn run_loop(
if let Err(ref _e) = result { if let Err(ref _e) = result {
let _ = terminal.clear(); let _ = terminal.clear();
let _ = execute!(io::stdout(), DisableMouseCapture);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = execute!(io::stdout(), LeaveAlternateScreen);
} }
+5 -11
View File
@@ -43,18 +43,12 @@ impl EditLog {
/// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk /// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk
/// regardless of the in-memory limit. /// regardless of the in-memory limit.
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> { fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
let Ok(file) = std::fs::File::open(path) else { return Vec::new() };
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut entries: Vec<EditLogEntry> = Vec::new(); let mut entries: Vec<EditLogEntry> = Vec::new();
for line in reader.lines() { for line in reader.lines() {
let line = match line { let Ok(line) = line else { continue };
Ok(l) => l,
Err(_) => continue,
};
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) { if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
// Keep only the most recent entries in memory // Keep only the most recent entries in memory
if entries.len() >= MAX_MEMORY_ENTRIES { if entries.len() >= MAX_MEMORY_ENTRIES {
@@ -81,6 +75,7 @@ impl EditLog {
/// Return: `Ok(())` on success; an `io::Error` if serialization or /// Return: `Ok(())` on success; an `io::Error` if serialization or
/// any filesystem operation fails. /// any filesystem operation fails.
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
use std::io::Write;
let line = serde_json::to_string(&entry)? + "\n"; let line = serde_json::to_string(&entry)? + "\n";
// Ensure parent directory exists; fall back to the current // Ensure parent directory exists; fall back to the current
// directory if path has no parent (should not happen in practice // directory if path has no parent (should not happen in practice
@@ -92,7 +87,6 @@ impl EditLog {
.create(true) .create(true)
.append(true) .append(true)
.open(&self.path)?; .open(&self.path)?;
use std::io::Write;
file.write_all(line.as_bytes())?; file.write_all(line.as_bytes())?;
file.sync_all()?; file.sync_all()?;
self.entries.push(entry); self.entries.push(entry);
@@ -151,8 +145,8 @@ mod tests {
log.append(EditLogEntry { log.append(EditLogEntry {
ts: i, ts: i,
tool: "edit".to_string(), tool: "edit".to_string(),
path: format!("file{}.txt", i), path: format!("file{i}.txt"),
reason: format!("reason {}", i), reason: format!("reason {i}"),
content_sha256: "hash".to_string(), content_sha256: "hash".to_string(),
bytes_delta: 10 + i, bytes_delta: 10 + i,
origin: "main".to_string(), origin: "main".to_string(),
+12 -14
View File
@@ -57,7 +57,7 @@ impl Memory {
/// to nothing, so a path is always produced. /// to nothing, so a path is always produced.
pub fn path(memory_dir: &Path, name: &str) -> PathBuf { pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
slug_path(memory_dir, &format!("{}.md", slug)) slug_path(memory_dir, &format!("{slug}.md"))
} }
/// Serialize this memory to markdown-with-frontmatter and write it /// Serialize this memory to markdown-with-frontmatter and write it
@@ -72,14 +72,15 @@ impl Memory {
/// ///
/// Return: `Ok(())` on success, or an `io::Error` from directory /// Return: `Ok(())` on success, or an `io::Error` from directory
/// creation, the temp write, or the rename. /// creation, the temp write, or the rename.
#[allow(clippy::suspicious_open_options)]
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> { pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
let path = Self::path(memory_dir, &self.name); let path = Self::path(memory_dir, &self.name);
let parent = path.parent().unwrap(); let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default(); let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {o}")).unwrap_or_default();
let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default(); let scope_line = self.scope.as_ref().map(|s| format!("scope: {s}")).unwrap_or_default();
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default(); let before_line = self.before_snippet.as_ref().map(|s| format!("before: {s}")).unwrap_or_default();
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default(); let after_line = self.after_snippet.as_ref().map(|s| format!("after: {s}")).unwrap_or_default();
let prov_line = if self.provenances.is_empty() { let prov_line = if self.provenances.is_empty() {
String::new() String::new()
} else { } else {
@@ -95,11 +96,11 @@ impl Memory {
// Write to temp file with fsync for crash safety (prevents // Write to temp file with fsync for crash safety (prevents
// partial writes surviving a power loss). // partial writes surviving a power loss).
{ {
use std::io::Write;
let mut f = std::fs::OpenOptions::new() let mut f = std::fs::OpenOptions::new()
.create(true) .create(true)
.write(true) .write(true)
.open(&tmp)?; .open(&tmp)?;
use std::io::Write;
f.write_all(content.as_bytes())?; f.write_all(content.as_bytes())?;
f.sync_all()?; f.sync_all()?;
} }
@@ -161,7 +162,7 @@ impl Memory {
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
provenances: front.get("provenances").cloned() provenances: front.get("provenances").cloned()
.map(|s| s.split(", ").map(|p| p.to_string()).collect()) .map(|s| s.split(", ").map(std::string::ToString::to_string).collect())
.unwrap_or_default(), .unwrap_or_default(),
}) })
} }
@@ -185,13 +186,10 @@ impl Memory {
/// Return: slugs (without extension); empty `Vec` if the directory /// Return: slugs (without extension); empty `Vec` if the directory
/// can't be read. /// can't be read.
pub fn list(memory_dir: &Path) -> Vec<String> { pub fn list(memory_dir: &Path) -> Vec<String> {
let entries = match std::fs::read_dir(memory_dir) { let Ok(entries) = std::fs::read_dir(memory_dir) else { return Vec::new() };
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries entries
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false)) .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| { .filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string(); let name = e.file_name().to_string_lossy().to_string();
if name == "MEMORY.md" { return None; } if name == "MEMORY.md" { return None; }
@@ -377,7 +375,7 @@ mod tests {
}; };
mem.write(&dir).unwrap(); mem.write(&dir).unwrap();
let names = Memory::list(&dir); let names = Memory::list(&dir);
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names); assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {names:?}");
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
//! Persistence and domain model layer: sessions, conversations, memory, //! Persistence and domain model layer: sessions, conversations, memory,
//! message log (SQLite), edit log, and app/settings config. //! message log (`SQLite`), edit log, and app/settings config.
pub mod app_config; pub mod app_config;
pub mod editlog; pub mod editlog;
+4 -4
View File
@@ -1,4 +1,4 @@
//! Binary blob storage in the message-log SQLite database (e.g. images, //! Binary blob storage in the message-log `SQLite` database (e.g. images,
//! attachments), keyed by session id and an arbitrary blob key. //! attachments), keyed by session id and an arbitrary blob key.
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
@@ -9,7 +9,7 @@ use anyhow::Result;
/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs` /// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs`
/// keyed on `(session_id, blob_key)`. /// keyed on `(session_id, blob_key)`.
/// ///
/// Return: `Ok(())` on success, or the underlying SQLite error. /// Return: `Ok(())` on success, or the underlying `SQLite` error.
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> { pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
let created_at = chrono::Utc::now().timestamp_millis(); let created_at = chrono::Utc::now().timestamp_millis();
conn.execute( conn.execute(
@@ -22,7 +22,7 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
/// Fetch a blob's bytes for a session by key. /// Fetch a blob's bytes for a session by key.
/// ///
/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row /// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row
/// exists, `Err` for any other SQLite failure. /// exists, `Err` for any other `SQLite` failure.
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> { pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
let result = conn.query_row( let result = conn.query_row(
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
@@ -52,7 +52,7 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul
/// List all blob keys stored for a session, oldest first. /// List all blob keys stored for a session, oldest first.
/// ///
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the /// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
/// underlying SQLite error. /// underlying `SQLite` error.
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> { pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC" "SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
+1 -1
View File
@@ -12,7 +12,7 @@ pub use query::insert_message;
/// schema is initialized. /// schema is initialized.
/// ///
/// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs → /// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs →
/// open a SQLite connection → run `schema::init_schema`. /// open a `SQLite` connection → run `schema::init_schema`.
/// ///
/// Return: an open, schema-ready `Connection`, or an error if any step /// Return: an open, schema-ready `Connection`, or an error if any step
/// fails. /// fails.
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::dto::chat::message::{ChatMessage, Role};
/// Insert a chat message into the session's message log. /// Insert a chat message into the session's message log.
/// ///
/// Flow: extract optional content/tool_call_id/tool_name → serialize /// Flow: extract optional `content/tool_call_id/tool_name` → serialize
/// `tool_calls` to a JSON string if present → map `Role` to its string /// `tool_calls` to a JSON string if present → map `Role` to its string
/// column value → `INSERT` the row with the current timestamp. /// column value → `INSERT` the row with the current timestamp.
/// ///
+2 -2
View File
@@ -1,4 +1,4 @@
//! SQLite schema definition for the message log database. //! `SQLite` schema definition for the message log database.
use rusqlite::Connection; use rusqlite::Connection;
use anyhow::Result; use anyhow::Result;
@@ -9,7 +9,7 @@ use anyhow::Result;
/// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe /// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe
/// to call on every `open_or_create`. /// to call on every `open_or_create`.
/// ///
/// Return: `Ok(())` on success, or the underlying SQLite error. /// Return: `Ok(())` on success, or the underlying `SQLite` error.
pub fn init_schema(conn: &Connection) -> Result<()> { pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
conn.execute_batch( conn.execute_batch(
+3 -6
View File
@@ -89,7 +89,7 @@ impl Session {
if id.contains('/') || id.contains('\\') || id.contains("..") { if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(std::io::Error::new( return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput, std::io::ErrorKind::InvalidInput,
format!("invalid session id '{}': must not contain path separators", id), format!("invalid session id '{id}': must not contain path separators"),
)); ));
} }
let path = base_dir.join("sessions").join(id).join("session.json"); let path = base_dir.join("sessions").join(id).join("session.json");
@@ -108,12 +108,9 @@ impl Session {
/// contains no valid sessions. /// contains no valid sessions.
pub fn list(base_dir: &Path) -> Vec<Self> { pub fn list(base_dir: &Path) -> Vec<Self> {
let sessions_dir = base_dir.join("sessions"); let sessions_dir = base_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) { let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Vec::new() };
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries entries
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.path().is_dir()) .filter(|e| e.path().is_dir())
.filter_map(|e| { .filter_map(|e| {
let id = e.file_name().to_string_lossy().to_string(); let id = e.file_name().to_string_lossy().to_string();
+9 -12
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! PID-file based advisory lock preventing two processes from operating on //! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently. //! the same session directory concurrently.
@@ -37,6 +38,7 @@ impl SessionLock {
/// ///
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live /// Return: `Ok(true)` if acquired, `Ok(false)` if another live
/// process holds it, `Err` on I/O failure. /// process holds it, `Err` on I/O failure.
#[allow(clippy::suspicious_open_options)]
pub fn try_lock(&self) -> std::io::Result<bool> { pub fn try_lock(&self) -> std::io::Result<bool> {
// Phase 1: try atomic create. If it succeeds, the lock is ours. // Phase 1: try atomic create. If it succeeds, the lock is ours.
match fs::OpenOptions::new() match fs::OpenOptions::new()
@@ -90,6 +92,7 @@ impl SessionLock {
/// Check whether a process with the given PID is currently alive and /// Check whether a process with the given PID is currently alive and
/// is actually a zesdex process (not a recycled PID from a different /// is actually a zesdex process (not a recycled PID from a different
/// program). /// program).
#[allow(clippy::unused_self)]
fn is_alive(&self, pid: u32) -> bool { fn is_alive(&self, pid: u32) -> bool {
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal // whether the process exists and the caller has permission to signal
@@ -102,18 +105,12 @@ impl SessionLock {
// from a different program would answer kill but shouldn't hold // from a different program would answer kill but shouldn't hold
// our lock). This is best-effort — /proc may not be available // our lock). This is best-effort — /proc may not be available
// on all platforms. // on all platforms.
let proc_exe = std::path::PathBuf::from(format!("/proc/{}/exe", pid)); let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
match std::fs::read_link(&proc_exe) { if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() {
Ok(target) => match std::env::current_exe() { if target != exe {
Ok(exe) => { return false;
if target != exe { }
return false; } else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ }
}
}
Err(_) => { /* cannot resolve own exe, trust kill check */ }
},
Err(_) => { /* /proc unavailable, trust kill check */ }
}
true true
} }
} }
+1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects. //! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
use std::io::{Read, Write}; use std::io::{Read, Write};
+5 -5
View File
@@ -72,13 +72,13 @@ impl OAuthManager {
.post(&self.config.token_url) .post(&self.config.token_url)
.form(&params) .form(&params)
.send() .send()
.map_err(|e| format!("token request failed: {}", e))?; .map_err(|e| format!("token request failed: {e}"))?;
let status = resp.status(); let status = resp.status();
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?; let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
if !status.is_success() { if !status.is_success() {
return Err(format!("token endpoint returned {}: {}", status, body)); return Err(format!("token endpoint returned {status}: {body}"));
} }
let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string(); let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string();
@@ -87,7 +87,7 @@ impl OAuthManager {
self.token = Some(OAuthToken { self.token = Some(OAuthToken {
access_token, access_token,
refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()), refresh_token: body["refresh_token"].as_str().map(std::string::ToString::to_string),
expires_at: now + expires_in, expires_at: now + expires_in,
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
}); });
@@ -98,7 +98,7 @@ impl OAuthManager {
/// Build the provider's authorization URL with PKCE and state params attached. /// Build the provider's authorization URL with PKCE and state params attached.
/// ///
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously /// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
/// this silently fell back to https://example.com, which produced a valid-looking /// this silently fell back to <https://example.com>, which produced a valid-looking
/// auth URL pointing at the wrong server and leaked client credentials in /// auth URL pointing at the wrong server and leaked client credentials in
/// query params. Returning an empty string signals failure to callers, who /// query params. Returning an empty string signals failure to callers, who
/// can prompt the user to fix the OAuth config instead of starting a flow /// can prompt the user to fix the OAuth config instead of starting a flow
+1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows. //! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows.
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+10 -10
View File
@@ -13,7 +13,7 @@ pub(crate) const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
pub const DEFAULT_API_KEY: &str = ""; pub const DEFAULT_API_KEY: &str = "";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const REQUEST_TIMEOUT: Duration = Duration::from_mins(1);
/// Blocking HTTP client for a single LLM provider endpoint. /// Blocking HTTP client for a single LLM provider endpoint.
/// ///
@@ -29,9 +29,9 @@ pub struct LlmClient {
impl LlmClient { impl LlmClient {
/// Construct a client, falling back to built-in defaults for empty inputs. /// Construct a client, falling back to built-in defaults for empty inputs.
/// ///
/// Flow: empty api_key/model → substitute defaults → build reqwest client /// Flow: empty `api_key/model` → substitute defaults → build reqwest client
/// with connect/request timeouts → if TLS config fails, retry with just /// with connect/request timeouts → if TLS config fails, retry with just
/// request timeout (no connect timeout) → normalize base_url. /// request timeout (no connect timeout) → normalize `base_url`.
/// ///
/// Why: empty strings are treated as "unset" rather than errors so callers /// Why: empty strings are treated as "unset" rather than errors so callers
/// can pass through unconfigured settings without special-casing them. /// can pass through unconfigured settings without special-casing them.
@@ -126,11 +126,11 @@ impl LlmClient {
let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> { let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> {
let resp = http_req.json(&req).send().map_err(|e| { let resp = http_req.json(&req).send().map_err(|e| {
if e.is_timeout() { if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT) anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
} else if e.is_connect() { } else if e.is_connect() {
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url) anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
} else { } else {
anyhow::anyhow!("API request failed: {}", e) anyhow::anyhow!("API request failed: {e}")
} }
})?; })?;
@@ -142,7 +142,7 @@ impl LlmClient {
let data: crate::dto::provider::response::ChatResponse = resp.json()?; let data: crate::dto::provider::response::ChatResponse = resp.json()?;
let usage = data.usage.map(|u| { let usage = data.usage.map(|u| {
(u.prompt_tokens.unwrap_or(0) as u64, u.completion_tokens.unwrap_or(0) as u64) (u64::from(u.prompt_tokens.unwrap_or(0)), u64::from(u.completion_tokens.unwrap_or(0)))
}); });
let message = data let message = data
.choices .choices
@@ -259,11 +259,11 @@ impl LlmClient {
let resp = http_req.json(req).send().map_err(|e| { let resp = http_req.json(req).send().map_err(|e| {
if e.is_timeout() { if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT) anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
} else if e.is_connect() { } else if e.is_connect() {
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url) anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
} else { } else {
anyhow::anyhow!("API request failed: {}", e) anyhow::anyhow!("API request failed: {e}")
} }
})?; })?;
@@ -282,7 +282,7 @@ impl LlmClient {
loop { loop {
let n = reader.read(&mut chunk_buf) let n = reader.read(&mut chunk_buf)
.map_err(|e| anyhow::anyhow!("stream read error: {}", e))?; .map_err(|e| anyhow::anyhow!("stream read error: {e}"))?;
if n == 0 { if n == 0 {
break; break;
} }
@@ -306,7 +306,7 @@ impl LlmClient {
usage = Some((*prompt_tokens, *completion_tokens)); usage = Some((*prompt_tokens, *completion_tokens));
} }
StreamEvent::Error(msg) => { StreamEvent::Error(msg) => {
anyhow::bail!("stream error: {}", msg); anyhow::bail!("stream error: {msg}");
} }
StreamEvent::Done => { StreamEvent::Done => {
turn.apply_event(&event); turn.apply_event(&event);
+3 -3
View File
@@ -43,7 +43,7 @@ impl Tool for BashOutput {
} }
match crate::app::bgbash::control::bash_output(&job_id) { match crate::app::bgbash::control::bash_output(&job_id) {
Some(lines) => Ok(lines.join("\n")), Some(lines) => Ok(lines.join("\n")),
None => Ok(format!("No new output from job '{}'", job_id)), None => Ok(format!("No new output from job '{job_id}'")),
} }
} }
} }
@@ -82,11 +82,11 @@ impl Tool for BashKill {
anyhow::bail!("invalid job_id format: expected UUID"); anyhow::bail!("invalid job_id format: expected UUID");
} }
crate::app::bgbash::control::bash_kill(&job_id)?; crate::app::bgbash::control::bash_kill(&job_id)?;
Ok(format!("Killed background job '{}'", job_id)) Ok(format!("Killed background job '{job_id}'"))
} }
} }
/// Validate that a job_id matches UUID v4 format (hex with dashes). /// Validate that a `job_id` matches UUID v4 format (hex with dashes).
fn is_valid_job_id(id: &str) -> bool { fn is_valid_job_id(id: &str) -> bool {
// UUID v4 format: 8-4-4-4-12 hex digits // UUID v4 format: 8-4-4-4-12 hex digits
let parts: Vec<&str> = id.split('-').collect(); let parts: Vec<&str> = id.split('-').collect();
+7 -7
View File
@@ -47,24 +47,24 @@ impl Tool for Delete {
} }
let metadata = path.metadata() let metadata = path.metadata()
.map_err(|e| anyhow!("failed to read metadata for '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to read metadata for '{rel}': {e}"))?;
if metadata.is_dir() { if metadata.is_dir() {
let is_empty = fs::read_dir(&path) let is_empty = fs::read_dir(&path)
.map_err(|e| anyhow!("failed to read directory '{}': {}", rel, e))? .map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
.next() .next()
.is_none(); .is_none();
if is_empty { if is_empty {
fs::remove_dir(&path) fs::remove_dir(&path)
.map_err(|e| anyhow!("failed to remove directory '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to remove directory '{rel}': {e}"))?;
Ok(format!("removed empty directory {}", rel)) Ok(format!("removed empty directory {rel}"))
} else { } else {
anyhow::bail!("directory '{}' is not empty (refusing to delete)", rel); anyhow::bail!("directory '{rel}' is not empty (refusing to delete)");
} }
} else { } else {
fs::remove_file(&path) fs::remove_file(&path)
.map_err(|e| anyhow!("failed to delete '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to delete '{rel}': {e}"))?;
Ok(format!("deleted {}", rel)) Ok(format!("deleted {rel}"))
} }
} }
} }
+9 -11
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Tool: `edit` — replace a substring in a file with a new string. //! Tool: `edit` — replace a substring in a file with a new string.
use std::fs; use std::fs;
@@ -70,25 +71,24 @@ impl Tool for Edit {
anyhow::bail!("'old' must be a non-empty string; use 'write' to replace entire file contents"); anyhow::bail!("'old' must be a non-empty string; use 'write' to replace entire file contents");
} }
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks); let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false); let replace_all = args.get("replace_all").and_then(serde_json::Value::as_bool).unwrap_or(false);
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() { if !path.exists() {
anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display()); anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display());
} }
if path.is_dir() { if path.is_dir() {
anyhow::bail!("'{}' is a directory, not a file", rel); anyhow::bail!("'{rel}' is a directory, not a file");
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
if !content.contains(&old) { if !content.contains(&old) {
anyhow::bail!("old string not found in '{}'", rel); anyhow::bail!("old string not found in '{rel}'");
} }
if !replace_all { if !replace_all {
let count = content.matches(&old).count(); let count = content.matches(&old).count();
if count > 1 { if count > 1 {
anyhow::bail!( anyhow::bail!(
"old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.", "old string appears {count} times in '{rel}'. Set replace_all=true to replace all occurrences, or provide a more specific match."
count, rel
); );
} }
} }
@@ -98,7 +98,7 @@ impl Tool for Edit {
content.replacen(&old, &new_str, 1) content.replacen(&old, &new_str, 1)
}; };
fs::write(&path, &new_content) fs::write(&path, &new_content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
let bytes_diff = if new_content.len() > content.len() { let bytes_diff = if new_content.len() > content.len() {
new_content.len() - content.len() new_content.len() - content.len()
} else { } else {
@@ -108,10 +108,8 @@ impl Tool for Edit {
// Never fail the edit because of this — LSP errors are surfaced as a // Never fail the edit because of this — LSP errors are surfaced as a
// trailing annotation on the success message instead. // trailing annotation on the success message instead.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) { lsp.did_change_file(&path);
Ok(()) => String::new(), String::new()
Err(e) => format!(" (LSP: {})", e),
}
} else { } else {
String::new() String::new()
}; };
+6 -6
View File
@@ -12,8 +12,8 @@ use anyhow::{Result, anyhow};
pub fn arg_str(args: &Value, name: &str) -> Result<String> { pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name) args.get(name)
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(std::string::ToString::to_string)
.ok_or_else(|| anyhow!("missing required argument: {}", name)) .ok_or_else(|| anyhow!("missing required argument: {name}"))
} }
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist. /// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
@@ -25,17 +25,17 @@ pub fn arg_str(args: &Value, name: &str) -> Result<String> {
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String { pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let in_ws = ctx.workspaces.iter().any(|w| { let in_ws = ctx.workspaces.iter().any(|w| {
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf()); let wc = w.canonicalize().unwrap_or_else(|_| w.clone());
canon.starts_with(&wc) canon.starts_with(&wc)
}); });
if !in_ws { if in_ws {
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
} else {
format!( format!(
"path '{}' is outside all workspace roots. Workspace roots: {}", "path '{}' is outside all workspace roots. Workspace roots: {}",
rel, rel,
ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ") ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ")
) )
} else {
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
} }
} }
+4 -3
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Tool: `read` — display file contents with line numbers. //! Tool: `read` — display file contents with line numbers.
use std::fs; use std::fs;
@@ -47,7 +48,7 @@ impl Tool for Read {
/// exist; a "is a directory" message if the path points at a directory. /// exist; a "is a directory" message if the path points at a directory.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?; let rel = arg_str(args, "path")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize); let limit = args.get("limit").and_then(serde_json::Value::as_u64).map(|v| v as usize);
let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) { let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) {
Ok(p) => p, Ok(p) => p,
Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)), Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)),
@@ -56,10 +57,10 @@ impl Tool for Read {
return Ok(not_found_help(ctx, &path, &rel)); return Ok(not_found_help(ctx, &path, &rel));
} }
if path.is_dir() { if path.is_dir() {
return Ok(format!("'{}' is a directory, not a file. Use ls or glob to list directory contents.", rel)); return Ok(format!("'{rel}' is a directory, not a file. Use ls or glob to list directory contents."));
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let total = lines.len(); let total = lines.len();
let take = limit.unwrap_or(total).min(total); let take = limit.unwrap_or(total).min(total);
+4 -6
View File
@@ -60,18 +60,16 @@ impl Tool for Write {
let path = resolve_path(&ctx.workspaces, &rel)?; let path = resolve_path(&ctx.workspaces, &rel)?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|e| anyhow!("failed to create parent directories for '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?;
} }
fs::write(&path, &content) fs::write(&path, &content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
// Notify the LSP server of the on-disk change so diagnostics stay in // Notify the LSP server of the on-disk change so diagnostics stay in
// sync. Never fails the write itself: a lock failure or LSP error is // sync. Never fails the write itself: a lock failure or LSP error is
// folded into the returned message instead of propagated as an Err. // folded into the returned message instead of propagated as an Err.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) { lsp.did_change_file(&path);
Ok(()) => String::new(), String::new()
Err(e) => format!(" (LSP: {})", e),
}
} else { } else {
String::new() String::new()
}; };
+2 -2
View File
@@ -48,11 +48,11 @@ impl Tool for GitCred {
.arg("credential") .arg("credential")
.arg(operation) .arg(operation)
.output() .output()
.map_err(|e| anyhow!("git credential failed: {}", e))?; .map_err(|e| anyhow!("git credential failed: {e}"))?;
if output.status.success() { if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(format!("{}{}", stdout, stderr)) Ok(format!("{stdout}{stderr}"))
} else { } else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim()) anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
+3 -3
View File
@@ -58,7 +58,7 @@ impl Tool for GitOperator {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect() .collect()
}) })
.ok_or_else(|| anyhow!("missing required argument: args"))?; .ok_or_else(|| anyhow!("missing required argument: args"))?;
@@ -67,12 +67,12 @@ impl Tool for GitOperator {
// of which tool the model uses. // of which tool the model uses.
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" ")); let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter) crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
.map_err(|e| anyhow!("blocked: {}", e))?; .map_err(|e| anyhow!("blocked: {e}"))?;
let output = Command::new("git") let output = Command::new("git")
.arg(&operation) .arg(&operation)
.args(&arg_list) .args(&arg_list)
.output() .output()
.map_err(|e| anyhow!("git {} failed: {}", operation, e))?; .map_err(|e| anyhow!("git {operation} failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) }; let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
+4 -4
View File
@@ -37,7 +37,7 @@ impl Tool for GitWorktree {
/// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`. /// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`.
/// ///
/// Flow: extract name/base_ref → create worktree dir under `ctx.worktrees_dir` → /// Flow: extract `name/base_ref` → create worktree dir under `ctx.worktrees_dir` →
/// spawn `git worktree add` → combine stdout/stderr. /// spawn `git worktree add` → combine stdout/stderr.
/// ///
/// Return: success message with combined output on success; error including exit /// Return: success message with combined output on success; error including exit
@@ -56,18 +56,18 @@ impl Tool for GitWorktree {
.to_string(); .to_string();
let worktree_path = ctx.worktrees_dir.join(&name); let worktree_path = ctx.worktrees_dir.join(&name);
std::fs::create_dir_all(&worktree_path) std::fs::create_dir_all(&worktree_path)
.map_err(|e| anyhow!("failed to create worktree directory: {}", e))?; .map_err(|e| anyhow!("failed to create worktree directory: {e}"))?;
let output = Command::new("git") let output = Command::new("git")
.args(["worktree", "add", "--checkout"]) .args(["worktree", "add", "--checkout"])
.arg(worktree_path.display().to_string()) .arg(worktree_path.display().to_string())
.arg(&base_ref) .arg(&base_ref)
.output() .output()
.map_err(|e| anyhow!("git worktree add failed: {}", e))?; .map_err(|e| anyhow!("git worktree add failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) }; let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
if output.status.success() { if output.status.success() {
Ok(format!("created worktree '{}' from '{}'\n{}", name, base_ref, combined)) Ok(format!("created worktree '{name}' from '{base_ref}'\n{combined}"))
} else { } else {
anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim()) anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim())
} }
+60 -63
View File
@@ -1,3 +1,5 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
use std::fmt::Write;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
@@ -59,7 +61,7 @@ impl Tool for LspConnect {
.unwrap_or_default(); .unwrap_or_default();
let mut manager = ctx.lsp_manager.lock() let mut manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
manager.connect(name, command, &extra_args, language_id)?; manager.connect(name, command, &extra_args, language_id)?;
// Auto-register this server's known extensions so lsp_diagnostics / // Auto-register this server's known extensions so lsp_diagnostics /
@@ -79,8 +81,7 @@ impl Tool for LspConnect {
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
Ok(format!( Ok(format!(
"Connected to LSP server '{}' (language: {})\nServer capabilities:\n{}", "Connected to LSP server '{name}' (language: {language_id})\nServer capabilities:\n{caps_summary}"
name, language_id, caps_summary
)) ))
} }
} }
@@ -132,15 +133,15 @@ impl Tool for LspDiagnostics {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found"))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
match client.collect_diagnostics(&uri, &language_id, text) { match client.collect_diagnostics(&uri, &language_id, text) {
Ok(diags) => { Ok(diags) => {
@@ -151,7 +152,7 @@ impl Tool for LspDiagnostics {
let mut output = String::from("Diagnostics:\n"); let mut output = String::from("Diagnostics:\n");
for d in &diags_array { for d in &diags_array {
let range = d.get("range").and_then(|r| r.get("start")); let range = d.get("range").and_then(|r| r.get("start"));
let severity = match d.get("severity").and_then(|s| s.as_i64()).unwrap_or(0) { let severity = match d.get("severity").and_then(serde_json::Value::as_i64).unwrap_or(0) {
1 => "ERROR", 1 => "ERROR",
2 => "WARNING", 2 => "WARNING",
3 => "INFO", 3 => "INFO",
@@ -159,13 +160,13 @@ impl Tool for LspDiagnostics {
_ => "NOTE", _ => "NOTE",
}; };
let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?"); let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?");
let line = range.and_then(|r| r.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); let line = range.and_then(|r| r.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let col = range.and_then(|r| r.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); let col = range.and_then(|r| r.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let code = d.get("code") let code = d.get("code")
.and_then(|c| c.as_str().or_else(|| c.as_i64().map(|n| Box::leak(Box::new(n.to_string()))).map(|s| s.as_str()))) .and_then(|c| c.as_str().or_else(|| c.as_i64().map(|n| Box::leak(Box::new(n.to_string()))).map(|s| s.as_str())))
.unwrap_or(""); .unwrap_or("");
let code_str = if code.is_empty() { String::new() } else { format!(" [{}]", code) }; let code_str = if code.is_empty() { String::new() } else { format!(" [{code}]") };
output.push_str(&format!(" {}:{}:{} - {}{}: {}\n", rel_path, line + 1, col, severity, code_str, message)); writeln!(output, " {}:{}:{} - {}{}: {}", rel_path, line + 1, col, severity, code_str, message).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -226,10 +227,10 @@ impl Tool for LspHover {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -238,20 +239,20 @@ impl Tool for LspHover {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| { .unwrap_or_else(|| {
args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string() args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string()
}); });
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.hover(&uri, line, column); let result = client.hover(&uri, line, column);
@@ -267,9 +268,9 @@ impl Tool for LspHover {
let mut output = String::new(); let mut output = String::new();
if let Some(range_val) = range { if let Some(range_val) = range {
if let Some(start) = range_val.get("start") { if let Some(start) = range_val.get("start") {
let rl = start.get("line").and_then(|l| l.as_i64()).unwrap_or(0); let rl = start.get("line").and_then(serde_json::Value::as_i64).unwrap_or(0);
let rc = start.get("character").and_then(|c| c.as_i64()).unwrap_or(0); let rc = start.get("character").and_then(serde_json::Value::as_i64).unwrap_or(0);
output.push_str(&format!("Range: {}:{}\n", rl + 1, rc + 1)); writeln!(output, "Range: {}:{}", rl + 1, rc + 1).unwrap();
} }
} }
if let Some(contents_val) = contents { if let Some(contents_val) = contents {
@@ -292,7 +293,7 @@ fn format_hover_contents(contents: &Value) -> String {
} }
Value::Object(map) => { Value::Object(map) => {
if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) { if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) {
out.push_str(&format!("[{kind}] ")); write!(out, "[{kind}] ").unwrap();
} }
if let Some(value) = map.get("value").and_then(|v| v.as_str()) { if let Some(value) = map.get("value").and_then(|v| v.as_str()) {
out.push_str(value); out.push_str(value);
@@ -355,10 +356,10 @@ impl Tool for LspCompletion {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -367,18 +368,18 @@ impl Tool for LspCompletion {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.completion(&uri, line, column); let result = client.completion(&uri, line, column);
@@ -401,7 +402,7 @@ impl Tool for LspCompletion {
let mut output = format!("{} completion suggestions at {}:{}:\n", items.len(), line + 1, column + 1); let mut output = format!("{} completion suggestions at {}:{}:\n", items.len(), line + 1, column + 1);
for (i, item) in items.iter().enumerate().take(50) { for (i, item) in items.iter().enumerate().take(50) {
let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?"); let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?");
let kind = match item.get("kind").and_then(|k| k.as_i64()).unwrap_or(0) { let kind = match item.get("kind").and_then(serde_json::Value::as_i64).unwrap_or(0) {
1 => "Text", 1 => "Text",
2 => "Method", 2 => "Method",
3 => "Function", 3 => "Function",
@@ -430,11 +431,11 @@ impl Tool for LspCompletion {
_ => "Other", _ => "Other",
}; };
let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or(""); let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or("");
let detail_str = if detail.is_empty() { String::new() } else { format!(" - {}", detail) }; let detail_str = if detail.is_empty() { String::new() } else { format!(" - {detail}") };
output.push_str(&format!(" {}. [{}] {}{}\n", i + 1, kind, label, detail_str)); writeln!(output, " {}. [{}] {}{}", i + 1, kind, label, detail_str).unwrap();
} }
if items.len() > 50 { if items.len() > 50 {
output.push_str(&format!(" ... and {} more\n", items.len() - 50)); writeln!(output, " ... and {} more", items.len() - 50).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -485,10 +486,10 @@ impl Tool for LspDefinition {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -497,18 +498,18 @@ impl Tool for LspDefinition {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.goto_definition(&uri, line, column); let result = client.goto_definition(&uri, line, column);
@@ -534,13 +535,13 @@ impl Tool for LspDefinition {
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
let target_range = loc.get("range").or_else(|| loc.get("targetRange")); let target_range = loc.get("range").or_else(|| loc.get("targetRange"));
let target_start = target_range.and_then(|r| r.get("start")); let target_start = target_range.and_then(|r| r.get("start"));
let tl = target_start.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); let tl = target_start.and_then(|s| s.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let tc = target_start.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); let tc = target_start.and_then(|s| s.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, tl + 1, tc + 1)); writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap();
} }
if locations.len() > 10 { if locations.len() > 10 {
output.push_str(&format!(" ... and {} more locations\n", locations.len() - 10)); writeln!(output, " ... and {} more", locations.len() - 10).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -591,10 +592,10 @@ impl Tool for LspReferences {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -603,18 +604,18 @@ impl Tool for LspReferences {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.references(&uri, line, column); let result = client.references(&uri, line, column);
@@ -631,13 +632,13 @@ impl Tool for LspReferences {
for (i, loc) in locations.iter().enumerate().take(50) { for (i, loc) in locations.iter().enumerate().take(50) {
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
let range = loc.get("range").and_then(|r| r.get("start")); let range = loc.get("range").and_then(|r| r.get("start"));
let rl = range.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); let rl = range.and_then(|s| s.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let rc = range.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); let rc = range.and_then(|s| s.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, rl + 1, rc + 1)); writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap();
} }
if locations.len() > 50 { if locations.len() > 50 {
output.push_str(&format!(" ... and {} more references\n", locations.len() - 50)); writeln!(output, " ... and {} more references", locations.len() - 50).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -676,12 +677,12 @@ impl Tool for LspDisconnect {
.ok_or_else(|| anyhow!("missing required argument: name"))?; .ok_or_else(|| anyhow!("missing required argument: name"))?;
let mut manager = ctx.lsp_manager.lock() let mut manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
if manager.disconnect(name) { if manager.disconnect(name) {
Ok(format!("Disconnected from LSP server '{}'", name)) Ok(format!("Disconnected from LSP server '{name}'"))
} else { } else {
Err(anyhow!("LSP server '{}' not found", name)) Err(anyhow!("LSP server '{name}' not found"))
} }
} }
} }
@@ -718,7 +719,7 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] {
/// connected server's language is known to use that extension. /// connected server's language is known to use that extension.
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> { fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?; let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?;
let dot_ext = format!(".{}", ext); let dot_ext = format!(".{ext}");
if let Ok(mgr) = ctx.lsp_manager.lock() { if let Ok(mgr) = ctx.lsp_manager.lock() {
for s in &mgr.servers { for s in &mgr.servers {
let exts = known_extensions_for(&s.language_id); let exts = known_extensions_for(&s.language_id);
@@ -754,15 +755,13 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String
let ext = std::path::Path::new(path) let ext = std::path::Path::new(path)
.extension() .extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str()).map_or_else(|| "<none>".to_string(), |e| format!(".{e}"));
.map(|e| format!(".{}", e))
.unwrap_or_else(|| "<none>".to_string());
let available = ctx.lsp_manager.lock().ok() let available = ctx.lsp_manager.lock().ok()
.map(|mgr| { .map(|mgr| {
mgr.list_servers() mgr.list_servers()
.iter() .iter()
.map(|(name, lang, _)| format!("{} ({})", name, lang)) .map(|(name, lang, _)| format!("{name} ({lang})"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
}) })
@@ -770,8 +769,6 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String
let available = if available.is_empty() { "none".to_string() } else { available }; let available = if available.is_empty() { "none".to_string() } else { available };
Err(anyhow!( Err(anyhow!(
"LSP server not found for extension '{}'. Use lsp_connect to connect one. Available servers: {}", "LSP server not found for extension '{ext}'. Use lsp_connect to connect one. Available servers: {available}"
ext,
available
)) ))
} }
+2 -2
View File
@@ -43,8 +43,8 @@ impl Tool for Forget {
.ok_or_else(|| anyhow!("missing required argument: name"))?; .ok_or_else(|| anyhow!("missing required argument: name"))?;
Memory::remove(&ctx.memory_dir, name) Memory::remove(&ctx.memory_dir, name)
.map_err(|e| anyhow!("failed to remove memory '{}': {}", name, e))?; .map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?;
Ok(format!("removed memory '{}'", name)) Ok(format!("removed memory '{name}'"))
} }
} }
+10 -9
View File
@@ -1,5 +1,6 @@
//! Tool for reading a single memory entry or listing the whole memory index. //! Tool for reading a single memory entry or listing the whole memory index.
use std::fmt::Write;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::super::Tool; use super::super::Tool;
@@ -39,10 +40,10 @@ impl Tool for Recall {
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if let Some(name) = args.get("name").and_then(|v| v.as_str()) { if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
if name.is_empty() { if name.is_empty() {
return list_all(ctx); return Ok(list_all(ctx));
} }
let memory = Memory::read(&ctx.memory_dir, name) let memory = Memory::read(&ctx.memory_dir, name)
.map_err(|e| anyhow!("memory '{}' not found: {}", name, e))?; .map_err(|e| anyhow!("memory '{name}' not found: {e}"))?;
Ok(format!( Ok(format!(
"---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}", "---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}",
memory.name, memory.name,
@@ -52,7 +53,7 @@ impl Tool for Recall {
memory.content, memory.content,
)) ))
} else { } else {
list_all(ctx) Ok(list_all(ctx))
} }
} }
} }
@@ -63,18 +64,18 @@ impl Tool for Recall {
/// fall back to bare name if the file can't be parsed. /// fall back to bare name if the file can't be parsed.
/// ///
/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)"). /// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)").
fn list_all(ctx: &ToolCtx) -> Result<String> { fn list_all(ctx: &ToolCtx) -> String {
let names = Memory::list(&ctx.memory_dir); let names = Memory::list(&ctx.memory_dir);
if names.is_empty() { if names.is_empty() {
return Ok("(no memory entries)".to_string()); return "(no memory entries)".to_string();
} }
let mut lines = format!("Memory index ({} entries):\n", names.len()); let mut lines = String::new();
for name in &names { for name in &names {
if let Ok(mem) = Memory::read(&ctx.memory_dir, name) { if let Ok(mem) = Memory::read(&ctx.memory_dir, name) {
lines.push_str(&format!("- {} [{}]: {}\n", name, mem.kind, mem.description)); let _ = writeln!(lines, "- {} [{}]: {}", name, mem.kind, mem.description);
} else { } else {
lines.push_str(&format!("- {}\n", name)); let _ = writeln!(lines, "- {name}");
} }
} }
Ok(lines) lines
} }
+2 -2
View File
@@ -89,8 +89,8 @@ impl Tool for Remember {
}; };
memory.write(&ctx.memory_dir) memory.write(&ctx.memory_dir)
.map_err(|e| anyhow!("failed to write memory '{}': {}", name, e))?; .map_err(|e| anyhow!("failed to write memory '{name}': {e}"))?;
Ok(format!("saved memory '{}' ({})", name, kind)) Ok(format!("saved memory '{name}' ({kind})"))
} }
} }
+17 -21
View File
@@ -141,7 +141,7 @@ impl ToolCtxBuilder {
/// Construct one instance of every built-in tool, in the fixed order exposed to the LLM. /// Construct one instance of every built-in tool, in the fixed order exposed to the LLM.
/// ///
/// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan, /// Return: boxed trait objects for all 37 tools (fs, search, bash, git, memory, plan,
/// workflow, utility). /// workflow, utility).
pub fn all_tools() -> Vec<Box<dyn Tool>> { pub fn all_tools() -> Vec<Box<dyn Tool>> {
vec![ vec![
@@ -162,6 +162,7 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
Box::new(super::tool::plan::PlanReady), Box::new(super::tool::plan::PlanReady),
Box::new(super::tool::workflow::WorkflowRun), Box::new(super::tool::workflow::WorkflowRun),
Box::new(super::tool::workflow::NoteFinding), Box::new(super::tool::workflow::NoteFinding),
Box::new(super::tool::workflow::ReadFindings),
Box::new(super::tool::workflow::CompanyPipeline), Box::new(super::tool::workflow::CompanyPipeline),
Box::new(super::tool::spawn::SpawnAgents), Box::new(super::tool::spawn::SpawnAgents),
Box::new(super::tool::spawn::SpawnPipeline), Box::new(super::tool::spawn::SpawnPipeline),
@@ -228,7 +229,7 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
} else { } else {
(0, rel) (0, rel)
}; };
let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?; let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
let abs = if path.is_empty() { let abs = if path.is_empty() {
base.clone() base.clone()
} else { } else {
@@ -239,32 +240,27 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
// workspace root first and then resolve parent-dir (`../`) traversal // workspace root first and then resolve parent-dir (`../`) traversal
// component-by-component so that `Path::starts_with` cannot be // component-by-component so that `Path::starts_with` cannot be
// bypassed by unnormalised intermediate segments. // bypassed by unnormalised intermediate segments.
let canon = match abs.canonicalize() { let canon = if let Ok(c) = abs.canonicalize() { c } else {
Ok(c) => c, let base_canon = workspaces
Err(_) => { .iter().find_map(|w| w.canonicalize().ok())
let base_canon = workspaces .unwrap_or_else(|| base.clone());
.iter() let mut resolved = base_canon.clone();
.filter_map(|w| w.canonicalize().ok()) if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
.next() for comp in rel_components.components() {
.unwrap_or_else(|| base.clone()); match comp {
let mut resolved = base_canon.clone(); std::path::Component::ParentDir => {
if let Ok(rel_components) = abs.strip_prefix(&base_canon) { resolved.pop();
for comp in rel_components.components() {
match comp {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
c => resolved.push(c),
} }
std::path::Component::CurDir => {}
c => resolved.push(c),
} }
} }
resolved
} }
resolved
}; };
if workspaces.iter().any(|w| canon.starts_with(w)) { if workspaces.iter().any(|w| canon.starts_with(w)) {
Ok(canon) Ok(canon)
} else { } else {
anyhow::bail!("path '{}' is outside all workspace roots", rel) anyhow::bail!("path '{rel}' is outside all workspace roots")
} }
} }
+8 -8
View File
@@ -59,10 +59,10 @@ impl Tool for Grep {
.to_string(); .to_string();
let path = resolve_path(&ctx.workspaces, &rel)?; let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() { if !path.exists() {
anyhow::bail!("path '{}' does not exist", rel); anyhow::bail!("path '{rel}' does not exist");
} }
if !path.is_dir() { if !path.is_dir() {
anyhow::bail!("path '{}' is not a directory", rel); anyhow::bail!("path '{rel}' is not a directory");
} }
let mut results: Vec<(String, usize, String)> = Vec::new(); let mut results: Vec<(String, usize, String)> = Vec::new();
for entry in Walk::new(&path).flatten() { for entry in Walk::new(&path).flatten() {
@@ -83,10 +83,10 @@ impl Tool for Grep {
} }
} }
if results.is_empty() { if results.is_empty() {
return Ok(format!("no matches found for '{}' in {}", pattern, rel)); return Ok(format!("no matches found for '{pattern}' in {rel}"));
} }
let output = results.iter() let output = results.iter()
.map(|(f, line, text)| format!("{}:{}:{}", f, line, text)) .map(|(f, line, text)| format!("{f}:{line}:{text}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
Ok(format!("found {} matches:\n{}", results.len(), output)) Ok(format!("found {} matches:\n{}", results.len(), output))
@@ -144,14 +144,14 @@ impl Tool for Glob {
.to_string(); .to_string();
let root = resolve_path(&ctx.workspaces, &rel)?; let root = resolve_path(&ctx.workspaces, &rel)?;
if !root.exists() || !root.is_dir() { if !root.exists() || !root.is_dir() {
anyhow::bail!("path '{}' is not a valid directory", rel); anyhow::bail!("path '{rel}' is not a valid directory");
} }
let mut builder = GlobSetBuilder::new(); let mut builder = GlobSetBuilder::new();
let full_pattern = root.join(&pat_str).display().to_string(); let full_pattern = root.join(&pat_str).display().to_string();
builder.add(GlobBuilder::new(&full_pattern).build() builder.add(GlobBuilder::new(&full_pattern).build()
.map_err(|e| anyhow!("invalid glob pattern '{}': {}", pat_str, e))?); .map_err(|e| anyhow!("invalid glob pattern '{pat_str}': {e}"))?);
let glob_set = builder.build() let glob_set = builder.build()
.map_err(|e| anyhow!("failed to build glob set: {}", e))?; .map_err(|e| anyhow!("failed to build glob set: {e}"))?;
let mut matches: Vec<String> = Vec::new(); let mut matches: Vec<String> = Vec::new();
for entry in Walk::new(&root).flatten() { for entry in Walk::new(&root).flatten() {
let p = entry.path(); let p = entry.path();
@@ -165,7 +165,7 @@ impl Tool for Glob {
} }
matches.sort(); matches.sort();
if matches.is_empty() { if matches.is_empty() {
return Ok(format!("no files match '{}' in {}", pat_str, rel)); return Ok(format!("no files match '{pat_str}' in {rel}"));
} }
Ok(matches.join("\n")) Ok(matches.join("\n"))
} }
+10 -10
View File
@@ -63,13 +63,13 @@ impl Tool for Bash {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: command"))? .ok_or_else(|| anyhow!("missing required argument: command"))?
.to_string(); .to_string();
let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120_000).min(600_000); let timeout_ms = args.get("timeout").and_then(serde_json::Value::as_u64).unwrap_or(120_000).min(600_000);
// Only gate destructive git operations; credential reads are allowed // Only gate destructive git operations; credential reads are allowed
// locally since the AI needs access, and the real threat is committing // locally since the AI needs access, and the real threat is committing
// secrets to a public repo (handled by git pre-commit hooks / user). // secrets to a public repo (handled by git pre-commit hooks / user).
super::shell_filter::git::check_git_destructive(&cmd) super::shell_filter::git::check_git_destructive(&cmd)
.map_err(|e| anyhow!("blocked: {}", e))?; .map_err(|e| anyhow!("blocked: {e}"))?;
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false); let run_in_background = args.get("run_in_background").and_then(serde_json::Value::as_bool).unwrap_or(false);
if run_in_background { if run_in_background {
let job = crate::app::bgbash::job::spawn_bash_job(cmd); let job = crate::app::bgbash::job::spawn_bash_job(cmd);
return Ok(format!("Background job: {}", job.id)); return Ok(format!("Background job: {}", job.id));
@@ -80,7 +80,7 @@ impl Tool for Bash {
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
.spawn() .spawn()
.map_err(|e| anyhow!("failed to spawn bash: {}", e))?; .map_err(|e| anyhow!("failed to spawn bash: {e}"))?;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let timeout = Duration::from_millis(timeout_ms); let timeout = Duration::from_millis(timeout_ms);
loop { loop {
@@ -88,16 +88,16 @@ impl Tool for Bash {
Ok(Some(status)) => { Ok(Some(status)) => {
let elapsed = start.elapsed().as_secs_f64(); let elapsed = start.elapsed().as_secs_f64();
let output = child.wait_with_output() let output = child.wait_with_output()
.map_err(|e| anyhow!("failed to collect output: {}", e))?; .map_err(|e| anyhow!("failed to collect output: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
let trimmed = combined.trim().to_string(); let trimmed = combined.trim().to_string();
if status.success() { if status.success() {
return Ok(if trimmed.is_empty() { return Ok(if trimmed.is_empty() {
format!("Command completed in {:.2}s (exit code 0)", elapsed) format!("Command completed in {elapsed:.2}s (exit code 0)")
} else { } else {
format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed) format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
}); });
} }
return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed)); return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed));
@@ -106,12 +106,12 @@ impl Tool for Bash {
if start.elapsed() > timeout { if start.elapsed() > timeout {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
anyhow::bail!("command timed out after {}ms", timeout_ms); anyhow::bail!("command timed out after {timeout_ms}ms");
} }
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
Err(e) => { Err(e) => {
anyhow::bail!("failed to wait for command: {}", e); anyhow::bail!("failed to wait for command: {e}");
} }
} }
} }
+2 -2
View File
@@ -56,7 +56,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes); let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
for pattern in &patterns { for pattern in &patterns {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) { if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
anyhow::bail!("destructive git operation blocked: '{}'", pattern); anyhow::bail!("destructive git operation blocked: '{pattern}'");
} }
} }
// Additional check: any `+` prefixed refspec in a `git push` is a // Additional check: any `+` prefixed refspec in a `git push` is a
@@ -69,7 +69,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
&cmd_no_quotes &cmd_no_quotes
}; };
if check_push.contains("push") { if check_push.contains("push") {
let push_end = cmd_no_quotes.find("push").map(|i| i + 4).unwrap_or(0); let push_end = cmd_no_quotes.find("push").map_or(0, |i| i + 4);
let after_push = &cmd_no_quotes[push_end..]; let after_push = &cmd_no_quotes[push_end..];
if after_push.contains('+') { if after_push.contains('+') {
anyhow::bail!("destructive git operation blocked: force push via +refspec"); anyhow::bail!("destructive git operation blocked: force push via +refspec");
+2 -2
View File
@@ -34,7 +34,7 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
Some('\'') => decoded.push('\''), Some('\'') => decoded.push('\''),
Some('x' | 'X') => { Some('x' | 'X') => {
// \xHH — hex escape (2 hex digits) // \xHH — hex escape (2 hex digits)
let hex: String = chars.by_ref().take(2).take_while(|c| c.is_ascii_hexdigit()).collect(); let hex: String = chars.by_ref().take(2).take_while(char::is_ascii_hexdigit).collect();
if hex.len() == 2 { if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) { if let Ok(byte) = u8::from_str_radix(&hex, 16) {
decoded.push(byte as char); decoded.push(byte as char);
@@ -47,7 +47,7 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
} }
Some('u') => { Some('u') => {
// \uNNNN — unicode escape (4 hex digits) // \uNNNN — unicode escape (4 hex digits)
let hex: String = chars.by_ref().take(4).take_while(|c| c.is_ascii_hexdigit()).collect(); let hex: String = chars.by_ref().take(4).take_while(char::is_ascii_hexdigit).collect();
if hex.len() == 4 { if hex.len() == 4 {
if let Ok(code) = u32::from_str_radix(&hex, 16) { if let Ok(code) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(code) { if let Some(c) = char::from_u32(code) {
+25 -22
View File
@@ -51,12 +51,13 @@ impl Tool for SpawnAgents {
}) })
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
use std::sync::{Arc, Mutex};
let agents: Vec<String> = args.get("agents") let agents: Vec<String> = args.get("agents")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("missing required argument: agents"))? .ok_or_else(|| anyhow!("missing required argument: agents"))?
.iter() .iter()
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect(); .collect();
if agents.is_empty() { if agents.is_empty() {
@@ -67,9 +68,8 @@ impl Tool for SpawnAgents {
} }
let max_concurrency = args.get("max_concurrency") let max_concurrency = args.get("max_concurrency")
.and_then(|v| v.as_u64()) .and_then(serde_json::Value::as_u64)
.map(|v| v.min(10) as usize) .map_or(10, |v| v.min(10) as usize);
.unwrap_or(10);
let agent_count = agents.len(); let agent_count = agents.len();
let primitives: Vec<ScriptPrimitive> = agents let primitives: Vec<ScriptPrimitive> = agents
@@ -78,8 +78,8 @@ impl Tool for SpawnAgents {
.collect(); .collect();
let wf = WorkflowScript { let wf = WorkflowScript {
name: format!("parallel-{}-agents", agent_count), name: format!("parallel-{agent_count}-agents"),
description: format!("Auto-spawned parallel workflow with {} agents", agent_count), description: format!("Auto-spawned parallel workflow with {agent_count} agents"),
script: ScriptPrimitive::Parallel(primitives), script: ScriptPrimitive::Parallel(primitives),
options: ScriptOptions { options: ScriptOptions {
max_concurrency, max_concurrency,
@@ -88,8 +88,7 @@ impl Tool for SpawnAgents {
}, },
}; };
use std::sync::{Arc, Mutex}; let live: Option<crate::app::workflow::engine::LiveStateFn> = ctx.turn_events.as_ref().map(|turn_events| {
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
let turn_events = turn_events.clone(); let turn_events = turn_events.clone();
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| { let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| {
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
@@ -107,18 +106,20 @@ impl Tool for SpawnAgents {
// by this tool call are isolated from any other concurrent // by this tool call are isolated from any other concurrent
// spawn_agents or workflow_run invocations. // spawn_agents or workflow_run invocations.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let results = crate::app::workflow::engine::execute_primitive( let results = crate::app::workflow::engine::execute_primitive(
&wf.script, &wf.script,
&HashMap::new(), &HashMap::new(),
max_concurrency, max_concurrency,
true, true,
&no_abort,
live.as_ref(), live.as_ref(),
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
&findings, &findings,
None, // no per-agent timeout for spawn_agents None, // no per-agent timeout for spawn_agents
)?; )?;
format_results(results, "parallel") Ok(format_results(&results, "parallel"))
} }
} }
@@ -150,12 +151,13 @@ impl Tool for SpawnPipeline {
}) })
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
use std::sync::{Arc, Mutex};
let stages: Vec<String> = args.get("stages") let stages: Vec<String> = args.get("stages")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("missing required argument: stages"))? .ok_or_else(|| anyhow!("missing required argument: stages"))?
.iter() .iter()
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect(); .collect();
if stages.is_empty() { if stages.is_empty() {
@@ -178,8 +180,7 @@ impl Tool for SpawnPipeline {
}, },
}; };
use std::sync::{Arc, Mutex}; let live: Option<crate::app::workflow::engine::LiveStateFn> = ctx.turn_events.as_ref().map(|turn_events| {
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
let turn_events = turn_events.clone(); let turn_events = turn_events.clone();
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| { let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| {
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
@@ -196,30 +197,32 @@ impl Tool for SpawnPipeline {
// Per-invocation findings scope isolates this pipeline from any // Per-invocation findings scope isolates this pipeline from any
// other concurrent spawn_agents / spawn_pipeline / workflow_run. // other concurrent spawn_agents / spawn_pipeline / workflow_run.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let results = crate::app::workflow::engine::execute_primitive( let results = crate::app::workflow::engine::execute_primitive(
&wf.script, &wf.script,
&HashMap::new(), &HashMap::new(),
1, 1,
false, false,
&no_abort,
live.as_ref(), live.as_ref(),
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
&findings, &findings,
None, // no per-agent timeout for spawn_pipeline None, // no per-agent timeout for spawn_pipeline
)?; )?;
format_results(results, "pipeline") Ok(format_results(&results, "pipeline"))
} }
} }
/// Format a list of agent results into a readable summary string. /// Format a list of agent results into a readable summary string.
fn format_results(results: Vec<String>, mode: &str) -> Result<String> { fn format_results(results: &[String], mode: &str) -> String {
if results.is_empty() { if results.is_empty() {
return Ok(format!("{} workflow completed with no output", mode)); return format!("{mode} workflow completed with no output");
} }
let formatted: Vec<String> = results let formatted: Vec<String> = results
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, r)| format!("=== Agent {} ===\n{}", i + 1, r.trim())) .map(|(i, r)| format!("=== Agent {} ===\n{}", i + 1, r.trim()))
.collect(); .collect();
Ok(formatted.join("\n\n")) formatted.join("\n\n")
} }
+3 -3
View File
@@ -69,19 +69,19 @@ impl Tool for DirCacheUpdate {
// Create a one-shot runtime so this tool works from any thread (the // Create a one-shot runtime so this tool works from any thread (the
// agent turn runs on a std::thread that has no tokio context). // agent turn runs on a std::thread that has no tokio context).
let rt = tokio::runtime::Runtime::new() let rt = tokio::runtime::Runtime::new()
.map_err(|e| anyhow!("failed to create temp runtime: {}", e))?; .map_err(|e| anyhow!("failed to create temp runtime: {e}"))?;
rt.block_on(async { rt.block_on(async {
let cache = dc.write().await; let cache = dc.write().await;
cache.set(entries).await; cache.set(entries).await;
}); });
Ok(format!("cached {} entries for {}", count, rel)) Ok(format!("cached {count} entries for {rel}"))
} }
} }
/// Non-recursively list the immediate entries of `path`. /// Non-recursively list the immediate entries of `path`.
/// ///
/// Flow: read_dir → flatten Ok entries → collect their paths. /// Flow: `read_dir` → flatten Ok entries → collect their paths.
/// ///
/// Why: silently skips unreadable entries (e.g. permission errors) /// Why: silently skips unreadable entries (e.g. permission errors)
/// rather than failing the whole cache update. /// rather than failing the whole cache update.
+4 -4
View File
@@ -67,13 +67,13 @@ impl Tool for DirList {
} }
let entries: Vec<String> = fs::read_dir(&path) let entries: Vec<String> = fs::read_dir(&path)
.map_err(|e| anyhow!("failed to read directory '{}': {}", rel, e))? .map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.map(|e| { .map(|e| {
let name = e.file_name().to_string_lossy().to_string(); let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
if is_dir { if is_dir {
format!("{}/", name) format!("{name}/")
} else { } else {
name name
} }
+1 -1
View File
@@ -39,6 +39,6 @@ impl Tool for Pong {
let msg = args.get("message") let msg = args.get("message")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("pong"); .unwrap_or("pong");
Ok(format!("pong: {}", msg)) Ok(format!("pong: {msg}"))
} }
} }
+4 -4
View File
@@ -36,9 +36,9 @@ impl Tool for Todofinish {
} }
let content = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read todo.md: {}", e))?; .map_err(|e| anyhow!("failed to read todo.md: {e}"))?;
let task_index = args.get("task_index").and_then(|v| v.as_i64()); let task_index = args.get("task_index").and_then(serde_json::Value::as_i64);
let mut new_content = String::new(); let mut new_content = String::new();
let mut task_count = 0; let mut task_count = 0;
@@ -70,10 +70,10 @@ impl Tool for Todofinish {
} }
std::fs::write(&path, new_content) std::fs::write(&path, new_content)
.map_err(|e| anyhow!("failed to write to todo.md: {}", e))?; .map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
if let Some(idx) = task_index { if let Some(idx) = task_index {
Ok(format!("Successfully marked task {} as finished.", idx)) Ok(format!("Successfully marked task {idx} as finished."))
} else { } else {
Ok("Successfully marked ALL tasks as finished.".to_string()) Ok("Successfully marked ALL tasks as finished.".to_string())
} }
+4 -4
View File
@@ -59,17 +59,17 @@ impl Tool for Todowrite {
let path: PathBuf = ctx.session_dir.join("todo.md"); let path: PathBuf = ctx.session_dir.join("todo.md");
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S"); let timestamp = now.format("%Y-%m-%d %H:%M:%S");
let line = format!("- [ ] {} ({})\n", task, timestamp); let line = format!("- [ ] {task} ({timestamp})\n");
fs::OpenOptions::new() fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(&path) .open(&path)
.map_err(|e| anyhow!("failed to open todo.md: {}", e))? .map_err(|e| anyhow!("failed to open todo.md: {e}"))?
.write_all(line.as_bytes()) .write_all(line.as_bytes())
.map_err(|e| anyhow!("failed to write to todo.md: {}", e))?; .map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
Ok(format!("added task to todo.md: {}", task)) Ok(format!("added task to todo.md: {task}"))
} }
} }
+86 -11
View File
@@ -58,14 +58,14 @@ impl Tool for WorkflowRun {
/// ///
/// Return: the workflow engine's output string, or an error if the /// Return: the workflow engine's output string, or an error if the
/// script argument is missing or fails to parse as JSON. /// script argument is missing or fails to parse as JSON.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let script_str = args.get("script") let script_str = args.get("script")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: script"))?; .ok_or_else(|| anyhow!("missing required argument: script"))?;
let workflow_script: crate::app::workflow::script::WorkflowScript = let workflow_script: crate::app::workflow::script::WorkflowScript =
serde_json::from_str(script_str) serde_json::from_str(script_str)
.map_err(|e| anyhow!("failed to parse workflow script: {}", e))?; .map_err(|e| anyhow!("failed to parse workflow script: {e}"))?;
let workflow_args: std::collections::HashMap<String, String> = args.get("args") let workflow_args: std::collections::HashMap<String, String> = args.get("args")
.and_then(|v| v.as_object()) .and_then(|v| v.as_object())
@@ -77,7 +77,7 @@ impl Tool for WorkflowRun {
.unwrap_or_default(); .unwrap_or_default();
crate::app::workflow::engine::run_workflow( crate::app::workflow::engine::run_workflow(
&workflow_script, &workflow_args, &_ctx.session_dir, &_ctx.workspaces, &workflow_script, &workflow_args, &ctx.session_dir, &ctx.workspaces,
) )
} }
} }
@@ -171,13 +171,26 @@ impl Tool for CompanyPipeline {
"enum": ["full", "quick"], "enum": ["full", "quick"],
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks", "description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks",
"default": "full" "default": "full"
},
"specialists": {
"type": "object",
"description": "Mapping from division name (Strategy, Engineering, Quality, Security, Documentation) to list of custom specialists. Each specialist is defined by a pair of [label, focus_description]. This parameter is mandatory. The CEO/main agent must fully define the specialized roles and focuses for every division in the pipeline to run.",
"additionalProperties": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "string" },
"minItems": 2,
"maxItems": 2
}
}
} }
}, },
"required": ["request"] "required": ["request", "specialists"]
}) })
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let request = args.get("request") let request = args.get("request")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: request"))?; .ok_or_else(|| anyhow!("missing required argument: request"))?;
@@ -186,23 +199,85 @@ impl Tool for CompanyPipeline {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("full"); .unwrap_or("full");
let custom_specialists: std::collections::HashMap<String, Vec<(String, String)>> = args.get("specialists")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter().map(|(k, v)| {
let specs = v.as_array().map(|arr| {
arr.iter().filter_map(|item| {
let pair = item.as_array()?;
let label = pair.first()?.as_str()?.to_string();
let focus = pair.get(1)?.as_str()?.to_string();
Some((label, focus))
}).collect()
}).unwrap_or_default();
(k.clone(), specs)
}).collect()
})
.ok_or_else(|| anyhow!("missing required argument: specialists"))?;
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
match mode { match mode {
"quick" => { "quick" => {
crate::app::workflow::company::run_company_pipeline_quick( crate::app::workflow::company::run_company_pipeline_quick(
request, request,
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
_ctx.turn_events.as_ref(), ctx.turn_events.as_ref(),
&no_abort,
&custom_specialists,
) )
} }
_ => { _ => {
crate::app::workflow::company::run_company_pipeline( crate::app::workflow::company::run_company_pipeline(
request, request,
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
_ctx.turn_events.as_ref(), ctx.turn_events.as_ref(),
&no_abort,
&custom_specialists,
) )
} }
} }
} }
} }
/// Tool that retrieves all findings shared by sibling agents in the current workflow run.
pub struct ReadFindings;
impl Tool for ReadFindings {
fn name(&self) -> &'static str {
"read_findings"
}
fn description(&self) -> &'static str {
"Retrieve all findings shared by sibling agents in the current workflow run. Use this to get real-time context updates from other divisions/subagents working in parallel."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
if let Some(ref findings) = ctx.workflow_findings {
let f = findings.lock().map_err(|e| anyhow!("poisoned lock: {e}"))?;
if f.is_empty() {
Ok("No findings recorded yet in this workflow run.".to_string())
} else {
let formatted = f
.iter()
.enumerate()
.map(|(i, f)| format!("{}. {}", i + 1, f))
.collect::<Vec<_>>()
.join("\n");
Ok(format!("Findings in this workflow run:\n{formatted}"))
}
} else {
Ok("No findings database available (called outside a workflow run).".to_string())
}
}
}
+8 -6
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Chat transcript panel rendering — message cards with role badges. //! Chat transcript panel rendering — message cards with role badges.
//! //!
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a
@@ -19,7 +20,7 @@ use ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
/// 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<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> { fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let mut current_spans = Vec::new(); let mut current_spans = Vec::new();
@@ -76,10 +77,11 @@ fn format_timestamp(ts: i64) -> String {
let secs = ts / 1000; let secs = ts / 1000;
let mins = (secs / 60) % 60; let mins = (secs / 60) % 60;
let hrs = (secs / 3600) % 24; let hrs = (secs / 3600) % 24;
format!("{:02}:{:02}", hrs, mins) format!("{hrs:02}:{mins:02}")
} }
/// Render the scrollable chat transcript panel with message card styling. /// Render the scrollable chat transcript panel with message card styling.
#[allow(clippy::too_many_lines)]
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let messages = &state.transcript_cache.messages; let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset; let scroll_offset = state.scroll.offset;
@@ -95,7 +97,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
}; };
// ── Render messages as cards ───────────────────────────────────────── // ── Render messages as cards ─────────────────────────────────────────
for (_msg_idx, msg) in messages.iter().enumerate() { for msg in messages {
let accent = role_accent_color(&msg.role); let accent = role_accent_color(&msg.role);
let badge = role_badge(&msg.role); let badge = role_badge(&msg.role);
let label = role_label(&msg.role); let label = role_label(&msg.role);
@@ -119,12 +121,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
), ),
// Role name // Role name
Span::styled( Span::styled(
format!(" {}", label), format!(" {label}"),
Style::default().fg(accent).add_modifier(Modifier::BOLD), Style::default().fg(accent).add_modifier(Modifier::BOLD),
), ),
// Timestamp // Timestamp
Span::styled( Span::styled(
if ts_str.is_empty() { String::new() } else { format!(" {}", ts_str) }, if ts_str.is_empty() { String::new() } else { format!(" {ts_str}") },
Style::default().fg(Theme::TEXT_DIM), Style::default().fg(Theme::TEXT_DIM),
), ),
]); ]);
@@ -176,7 +178,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!(" {} ", spinner), format!(" {spinner} "),
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
+11 -25
View File
@@ -14,12 +14,13 @@ use super::theme::Theme;
/// Render a markdown string into styled terminal spans, word-wrapped to `width`. /// Render a markdown string into styled terminal spans, word-wrapped to `width`.
/// ///
/// Flow: pulldown_cmark parses `text` into an event stream → each /// Flow: `pulldown_cmark` parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s → /// Start/End/Text/Code/Break event is translated into styled `Span`s →
/// if `width > 0`, a second pass wraps long lines. /// if `width > 0`, a second pass wraps long lines.
/// ///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` /// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget. /// turns it back into `Line`s for the Paragraph widget.
#[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 parser = pulldown_cmark::Parser::new(text);
@@ -61,9 +62,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
pulldown_cmark::Tag::Paragraph => { pulldown_cmark::Tag::Paragraph => {
first_in_paragraph = true; first_in_paragraph = true;
} }
pulldown_cmark::Tag::Emphasis => {}
pulldown_cmark::Tag::Strong => {}
pulldown_cmark::Tag::List(_) => {}
pulldown_cmark::Tag::Item => { pulldown_cmark::Tag::Item => {
// List item bullet // List item bullet
spans.push(Span::styled( spans.push(Span::styled(
@@ -79,7 +77,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
// We push the URL as a tooltip-like suffix // We push the URL as a tooltip-like suffix
// After the link text ends, we'll add the URL // After the link text ends, we'll add the URL
spans.push(Span::styled( spans.push(Span::styled(
format!("]({})", dest_url), format!("]({dest_url})"),
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
)); ));
} }
@@ -111,14 +109,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
first_in_paragraph = true; first_in_paragraph = true;
spans.push(Span::raw("\n\n")); spans.push(Span::raw("\n\n"));
} }
pulldown_cmark::TagEnd::Emphasis => {} pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
pulldown_cmark::TagEnd::Strong => {}
pulldown_cmark::TagEnd::List(_) => {}
pulldown_cmark::TagEnd::Item => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::Link => {}
pulldown_cmark::TagEnd::BlockQuote(_) => {
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
_ => {} _ => {}
@@ -128,7 +119,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let s = text.to_string(); let s = text.to_string();
if in_code_block { if in_code_block {
spans.push(Span::styled( spans.push(Span::styled(
format!(" {}", s), format!(" {s}"),
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 {
@@ -138,14 +129,9 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
3 => Theme::ACCENT_PURPLE, 3 => Theme::ACCENT_PURPLE,
_ => Theme::TEXT, _ => Theme::TEXT,
}; };
let prefix = match heading_level { let prefix = " ";
1 => " ",
2 => " ",
3 => " ",
_ => " ",
};
spans.push(Span::styled( spans.push(Span::styled(
format!("{}{}", prefix, s), format!("{prefix}{s}"),
Style::default().fg(color).add_modifier(Modifier::BOLD), Style::default().fg(color).add_modifier(Modifier::BOLD),
)); ));
} else { } else {
@@ -160,7 +146,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
pulldown_cmark::Event::Code(text) => { pulldown_cmark::Event::Code(text) => {
// Inline code with background // Inline code with background
spans.push(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)
@@ -195,10 +181,10 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
spans_out.push(Span::styled(text_str.to_string(), style)); spans_out.push(Span::styled(text_str.to_string(), style));
if !text_str.contains('\n') { if text_str.contains('\n') {
line_len += remaining;
} else {
line_len = text_str.split('\n').next_back().unwrap_or("").len(); line_len = text_str.split('\n').next_back().unwrap_or("").len();
} else {
line_len += remaining;
} }
} }
spans = spans_out; spans = spans_out;
+39 -39
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Top-level TUI render pipeline: layouts the terminal into chat / input //! Top-level TUI render pipeline: layouts the terminal into chat / input
//! / status regions, dispatches overlay rendering with glassmorphism-style //! / status regions, dispatches overlay rendering with glassmorphism-style
//! centered panels, and floats toast notifications over the top-right corner. //! centered panels, and floats toast notifications over the top-right corner.
@@ -124,6 +125,7 @@ fn render_main_panel(
/// - A top accent border strip (colored per variant) /// - A top accent border strip (colored per variant)
/// - A title line with icon /// - A title line with icon
/// - Content area with proper spacing /// - Content area with proper spacing
#[allow(clippy::too_many_lines)]
fn render_overlay( fn render_overlay(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
@@ -172,12 +174,12 @@ fn render_overlay(
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Max tokens: {}", format!(" Max tokens: {}",
state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())), state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Temperature: {}", format!(" Temperature: {}",
state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())), state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
@@ -261,11 +263,11 @@ fn render_overlay(
input_text.as_str() input_text.as_str()
} }
}; };
let masked = if !input_text.is_empty() { let masked = if input_text.is_empty() {
let suffix = if input_text.len() > 8 { "****" } else { "" };
format!("{}{}", display, suffix)
} else {
display.to_string() display.to_string()
} else {
let suffix = if input_text.len() > 8 { "****" } else { "" };
format!("{display}{suffix}")
}; };
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -329,9 +331,9 @@ fn render_overlay(
let selected = i == current_idx; let selected = i == current_idx;
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
if selected { if selected {
format!("{} (active)", l) format!("{l} (active)")
} else { } else {
format!(" {}", l) format!(" {l}")
}, },
if selected { if selected {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
@@ -386,7 +388,7 @@ fn render_overlay(
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Messages: {}", msg_count), format!(" Messages: {msg_count}"),
Style::default().fg(Theme::INFO), Style::default().fg(Theme::INFO),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
@@ -427,7 +429,7 @@ fn render_overlay(
}; };
let preview: String = msg.content.chars().take(70).collect(); let preview: String = msg.content.chars().take(70).collect();
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!(" [{}] {}", role_str, preview), format!(" [{role_str}] {preview}"),
Style::default().fg( Style::default().fg(
if matches!(msg.role, crate::dto::chat::message::Role::User) { if matches!(msg.role, crate::dto::chat::message::Role::User) {
Theme::INFO Theme::INFO
@@ -484,7 +486,7 @@ fn render_overlay(
let (label, style) = match item { let (label, style) = match item {
crate::app::mode::learning::LearningItem::Pending { name, .. } => { crate::app::mode::learning::LearningItem::Pending { name, .. } => {
( (
format!("{}[Pending] {}", prefix, name), format!("{prefix}[Pending] {name}"),
if is_selected { if is_selected {
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM) Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
@@ -496,7 +498,7 @@ fn render_overlay(
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => { crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
let status = if lifecycle == "stale" { "Stale" } else { "Active" }; let status = if lifecycle == "stale" { "Stale" } else { "Active" };
( (
format!("{}[{}] {}", prefix, status, name), format!("{prefix}[{status}] {name}"),
if is_selected { if is_selected {
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM) Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
@@ -539,7 +541,7 @@ fn render_overlay(
" Name:", Style::default().fg(Theme::TEXT_DIM), " Name:", Style::default().fg(Theme::TEXT_DIM),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", name), format!(" {name}"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -548,11 +550,11 @@ fn render_overlay(
Style::default().fg(Theme::WARNING), Style::default().fg(Theme::WARNING),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Scope: {}", scope), format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Confidence: {}", confidence), format!(" Confidence: {confidence}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -561,7 +563,7 @@ fn render_overlay(
))); )));
for line in content.lines() { for line in content.lines() {
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", line), format!(" {line}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
} }
@@ -578,7 +580,7 @@ fn render_overlay(
" Name:", Style::default().fg(Theme::TEXT_DIM), " Name:", Style::default().fg(Theme::TEXT_DIM),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", name), format!(" {name}"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -588,15 +590,15 @@ fn render_overlay(
Theme::SUCCESS Theme::SUCCESS
}; };
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Status: {}", lifecycle), format!(" Status: {lifecycle}"),
Style::default().fg(status_color), Style::default().fg(status_color),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Scope: {}", scope), format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Description: {}", description), format!(" Description: {description}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -605,7 +607,7 @@ fn render_overlay(
))); )));
for line in content.lines() { for line in content.lines() {
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", line), format!(" {line}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
} }
@@ -635,7 +637,7 @@ fn render_overlay(
.border_style(Style::default().fg(Theme::INFO)); .border_style(Style::default().fg(Theme::INFO));
let runtime = state.session_runtime.as_ref(); let runtime = state.session_runtime.as_ref();
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime
.map(|r| { .map_or((0, 0, 0, 0, 0), |r| {
( (
r.usage.tokens_in, r.usage.tokens_in,
r.usage.tokens_out, r.usage.tokens_out,
@@ -643,18 +645,16 @@ fn render_overlay(
r.usage.review_tokens, r.usage.review_tokens,
r.session_start, r.session_start,
) )
}) });
.unwrap_or((0, 0, 0, 0, 0));
let (edit_count, lesson_count, review_count, consec_empty) = runtime let (edit_count, lesson_count, review_count, consec_empty) = runtime
.map(|r| { .map_or((0, 0, 0, 0), |r| {
( (
r.edit_count, r.edit_count,
r.lesson_count, r.lesson_count,
r.review_count, r.review_count,
r.consecutive_empty_reviews, r.consecutive_empty_reviews,
) )
}) });
.unwrap_or((0, 0, 0, 0));
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
let hours = elapsed_ms / 3_600_000; let hours = elapsed_ms / 3_600_000;
let minutes = (elapsed_ms % 3_600_000) / 60_000; let minutes = (elapsed_ms % 3_600_000) / 60_000;
@@ -669,19 +669,19 @@ fn render_overlay(
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Main agent: {} tokens", main_tokens), format!(" Main agent: {main_tokens} tokens"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Self-learning: {} tokens", self_learning_total), format!(" Self-learning: {self_learning_total} tokens"),
Style::default().fg(Theme::TEXT_MUTED), Style::default().fg(Theme::TEXT_MUTED),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Total: {} tokens", total_tokens), format!(" Total: {total_tokens} tokens"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" API calls: {}", api_calls), format!(" API calls: {api_calls}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
@@ -690,21 +690,21 @@ fn render_overlay(
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Edits: {}", edit_count), format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Reviews: {}", review_count), format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Lessons: {}", lesson_count), format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED), Style::default().fg(Theme::TEXT_MUTED),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Empty reviews: {}", format!(" Empty reviews: {}",
if consec_empty > 3 { if consec_empty > 3 {
format!("{}", consec_empty) format!("{consec_empty}")
} else { } else {
consec_empty.to_string() consec_empty.to_string()
}, },
@@ -713,7 +713,7 @@ fn render_overlay(
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Session: {}h {}m {}s", hours, minutes, seconds), format!(" Session: {hours}h {minutes}m {seconds}s"),
Style::default().fg(Theme::TEXT_DIM), Style::default().fg(Theme::TEXT_DIM),
)), )),
]; ];
@@ -757,7 +757,7 @@ fn render_overlay(
let is_selected = i == state.misc.selected_index; let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " }; let prefix = if is_selected { "" } else { " " };
let model_str = cfg.default_model.as_deref().unwrap_or("(any)"); let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
let label = format!("{}{} ({})", prefix, name, model_str); let label = format!("{prefix}{name} ({model_str})");
let style = if is_current { let style = if is_current {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
} else if is_selected { } else if is_selected {
@@ -842,7 +842,7 @@ fn render_input_bar(
} else { } else {
Style::default().fg(Theme::TEXT) Style::default().fg(Theme::TEXT)
}; };
let label = format!("{}{}", prefix, candidate); let label = format!("{prefix}{candidate}");
lines.push(Line::from(Span::styled(label, style))); lines.push(Line::from(Span::styled(label, style)));
} }
let dropdown = Paragraph::new(lines).block(dropdown_block); let dropdown = Paragraph::new(lines).block(dropdown_block);
@@ -901,7 +901,7 @@ fn render_input_bar(
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
/// Render active toasts as a floating stack at top-right of the terminal. /// Render active toasts as a floating stack at top-right of the terminal.
/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once. /// Each toast auto-expires after its `lifetime_ms`. Max 4 visible at once.
/// ///
/// Toasts are stacked vertically with a 1-line gap. Each has a colored /// Toasts are stacked vertically with a 1-line gap. Each has a colored
/// left border and a subtle background. /// left border and a subtle background.
+8 -7
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Status bar rendering for the TUI — modern segmented bar design. //! Status bar rendering for the TUI — modern segmented bar design.
//! //!
//! Flow: `draw_status_bar` reads live connection/turn state off //! Flow: `draw_status_bar` reads live connection/turn state off
@@ -20,14 +21,15 @@ use super::theme::Theme;
/// Layout (left-to-right, space-filling): /// Layout (left-to-right, space-filling):
/// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI) /// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI)
/// CENTER: spinner + optional contextual info /// CENTER: spinner + optional contextual info
/// RIGHT: provider · model · ↑tokens_in ↓tokens_out /// RIGHT: provider · model · ↑`tokens_in``tokens_out`
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
use ratatui::layout::{Constraint, Direction, Layout};
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""]; let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
// ── Agent status badge ──────────────────────────────────────────────── // ── Agent status badge ────────────────────────────────────────────────
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() { let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
(format!(" {} PROG ", f), Theme::MODE_YOLO, Theme::BG) (format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
} else if state.misc.api_connected { } else if state.misc.api_connected {
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG) (" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
} else { } else {
@@ -61,7 +63,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
let total_chars: usize = rt.messages.iter() let total_chars: usize = rt.messages.iter()
.filter_map(|m| m.content.as_deref()) .filter_map(|m| m.content.as_deref())
.map(|c| c.len()) .map(str::len)
.sum(); .sum();
let current_tokens = total_chars / 4; let current_tokens = total_chars / 4;
@@ -69,8 +71,8 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {
parts.push(format!("{}{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out)); parts.push(format!("{}{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out));
} }
let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string()); let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string());
parts.push(format!("{}/{}", current_tokens, max_str)); parts.push(format!("{current_tokens}/{max_str}"));
parts.push(state.settings.provider.clone()); parts.push(state.settings.provider.clone());
parts.push(state.settings.model.clone()); parts.push(state.settings.model.clone());
@@ -79,7 +81,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
let max_tokens = state.app_config.model_roles.values() let max_tokens = state.app_config.model_roles.values()
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model) .find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
.and_then(|role| role.context_window); .and_then(|role| role.context_window);
let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string()); let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string());
format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model) format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model)
}; };
@@ -92,7 +94,6 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
)); ));
// Render the bar using two columns // Render the bar using two columns
use ratatui::layout::{Constraint, Direction, Layout};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
+6 -5
View File
@@ -55,6 +55,7 @@ fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -
} }
/// Render the workflow status panel. /// Render the workflow status panel.
#[allow(clippy::too_many_lines)]
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
@@ -179,7 +180,7 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
// Agent card header // Agent card header
card_lines.push(Line::from(vec![ card_lines.push(Line::from(vec![
Span::styled( Span::styled(
format!(" {} ", icon), format!(" {icon} "),
Style::default().fg(color).add_modifier(Modifier::BOLD), Style::default().fg(color).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
@@ -187,7 +188,7 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!(" [{}]", label), format!(" [{label}]"),
Style::default().fg(color), Style::default().fg(color),
), ),
Span::styled( Span::styled(
@@ -240,18 +241,18 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
])); ]));
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", tool_count), Style::default().fg(Theme::SUCCESS)), Span::styled(format!(" {tool_count}"), Style::default().fg(Theme::SUCCESS)),
])); ]));
if pending > 0 { if pending > 0 {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", pending), Style::default().fg(Theme::WARNING)), Span::styled(format!(" {pending}"), Style::default().fg(Theme::WARNING)),
])); ]));
} }
if bash_count > 0 { if bash_count > 0 {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", bash_count), Style::default().fg(Theme::WARNING)), Span::styled(format!(" {bash_count}"), Style::default().fg(Theme::WARNING)),
])); ]));
} }
} else { } else {