From a00aa9bec87ba5e09897be55cc28211f04ecc0f0 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 16 Jul 2026 07:42:03 +0700 Subject: [PATCH] Refactor view modules for improved readability and consistency - Updated markdown rendering logic to use more concise methods for obtaining vector lengths. - Changed review status display to use the correct flag from settings. - Cleaned up sidebar rendering code for better formatting and readability. - Enhanced status bar rendering with improved string formatting and consistent style application. - Refined workflow panel rendering, ensuring consistent style usage and improved readability. - Added architecture overview and detailed documentation for backend, data, dependencies, and frontend structures. --- docs/CODEMAPS/architecture.md | 65 +++++ docs/CODEMAPS/backend.md | 68 +++++ docs/CODEMAPS/data.md | 89 ++++++ docs/CODEMAPS/dependencies.md | 99 +++++++ docs/CODEMAPS/frontend.md | 79 ++++++ src/app/bgbash/control.rs | 18 +- src/app/bgbash/job.rs | 32 ++- src/app/bgbash/mod.rs | 1 - src/app/harness.rs | 389 ++++++++++++++++----------- src/app/lsp/client.rs | 239 ++++++++-------- src/app/lsp/mod.rs | 167 ++++-------- src/app/lsp/provisioner.rs | 366 ++++++++++++++----------- src/app/mcp/manager.rs | 220 ++++++++------- src/app/mcp/mod.rs | 1 - src/app/mod.rs | 10 +- src/app/mode/bash.rs | 1 - src/app/mode/editor.rs | 10 +- src/app/mode/effort.rs | 8 +- src/app/mode/help.rs | 1 - src/app/mode/key_input.rs | 1 - src/app/mode/learning.rs | 6 +- src/app/mode/loading.rs | 1 - src/app/mode/mcp.rs | 1 - src/app/mode/mod.rs | 3 +- src/app/mode/quit_confirm.rs | 1 - src/app/mode/rewind.rs | 25 +- src/app/mode/settings.rs | 3 +- src/app/mode/todo.rs | 1 - src/app/review/mod.rs | 2 +- src/app/runtime/actions/mod.rs | 201 +++++--------- src/app/runtime/commands.rs | 2 +- src/app/runtime/context/dedup.rs | 61 +++-- src/app/runtime/context/mod.rs | 1 - src/app/runtime/context/shaping.rs | 20 +- src/app/runtime/context/squash.rs | 60 ++++- src/app/runtime/context/tokens.rs | 5 +- src/app/runtime/context/window.rs | 49 ++-- src/app/runtime/stream/mod.rs | 153 +++++------ src/app/runtime/stream/tools/mod.rs | 101 ------- src/app/runtime/stream/turn.rs | 66 +++-- src/app/state/misc.rs | 8 +- src/app/state/rest.rs | 2 +- src/app/state/runtime.rs | 3 +- src/app/state/snapshot.rs | 1 - src/app/state/types.rs | 9 +- src/app/subagent/auto.rs | 112 +++++--- src/app/subagent/context.rs | 10 +- src/app/subagent/division.rs | 74 ++++- src/app/subagent/engine.rs | 2 +- src/app/subagent/event.rs | 12 +- src/app/subagent/mod.rs | 1 - src/app/subagent/spawn.rs | 8 - src/app/workflow/docs.rs | 44 +-- src/app/workflow/engine.rs | 217 ++++++++++----- src/app/workflow/hive_mind.rs | 245 +++++++++++------ src/app/workflow/mod.rs | 3 +- src/app/workflow/script.rs | 1 - src/controller/command.rs | 22 +- src/controller/input.rs | 95 +++++-- src/controller/mod.rs | 1 - src/dto/chat/message.rs | 16 +- src/dto/chat/mod.rs | 1 - src/dto/chat/tool.rs | 22 +- src/dto/mod.rs | 1 - src/dto/provider/mod.rs | 1 - src/dto/provider/request.rs | 1 - src/dto/provider/response.rs | 1 - src/dto/provider/usage.rs | 1 - src/ipc/client.rs | 3 +- src/ipc/conn.rs | 5 +- src/ipc/diff.rs | 1 - src/ipc/frame.rs | 10 +- src/ipc/mod.rs | 1 - src/ipc/protocol.rs | 1 - src/ipc/server.rs | 5 +- src/ipc/snapshot.rs | 1 - src/main.rs | 112 +++++--- src/model/agent_def/builtin.rs | 1 - src/model/agent_def/global.rs | 1 - src/model/agent_def/mod.rs | 1 - src/model/agent_def/session.rs | 1 - src/model/app_config.rs | 74 ++--- src/model/conversation.rs | 1 - src/model/editlog.rs | 8 +- src/model/memory.rs | 113 ++++++-- src/model/mod.rs | 1 - src/model/msglog/blobs.rs | 39 ++- src/model/msglog/mod.rs | 1 - src/model/msglog/query.rs | 12 +- src/model/msglog/schema.rs | 5 +- src/model/msglog/summary.rs | 1 - src/model/session.rs | 9 +- src/model/session_lock.rs | 29 +- src/model/settings.rs | 74 ++--- src/model/store.rs | 3 +- src/resources.rs | 1 - src/service/mod.rs | 3 +- src/service/oauth/loopback.rs | 30 ++- src/service/oauth/manager.rs | 36 ++- src/service/oauth/mod.rs | 3 +- src/service/oauth/pkce.rs | 12 +- src/service/provider.rs | 43 +-- src/tool/bash_tools.rs | 15 +- src/tool/fs/delete.rs | 23 +- src/tool/fs/edit.rs | 68 +++-- src/tool/fs/helpers.rs | 27 +- src/tool/fs/mod.rs | 1 - src/tool/fs/read.rs | 38 ++- src/tool/fs/write.rs | 54 ++-- src/tool/git_cred.rs | 10 +- src/tool/git_operator.rs | 26 +- src/tool/git_worktree.rs | 31 ++- src/tool/lsp/mod.rs | 329 +++++++++++++++------- src/tool/memory/forget.rs | 8 +- src/tool/memory/mod.rs | 1 - src/tool/memory/recall.rs | 13 +- src/tool/memory/remember.rs | 20 +- src/tool/mod.rs | 60 +++-- src/tool/plan.rs | 14 +- src/tool/search.rs | 46 ++-- src/tool/seqthink.rs | 5 +- src/tool/shell.rs | 39 ++- src/tool/shell_filter/credentials.rs | 1 - src/tool/shell_filter/git.rs | 9 +- src/tool/shell_filter/mod.rs | 22 +- src/tool/spawn.rs | 94 ++++--- src/tool/utility/cd.rs | 20 +- src/tool/utility/dir_cache_update.rs | 14 +- src/tool/utility/dir_list.rs | 22 +- src/tool/utility/mod.rs | 3 +- src/tool/utility/pong.rs | 8 +- src/tool/utility/todofinish.rs | 11 +- src/tool/utility/todowrite.rs | 12 +- src/tool/workflow.rs | 45 ++-- src/view/chat.rs | 75 ++++-- src/view/markdown.rs | 4 +- src/view/mod.rs | 2 +- src/view/sidebar.rs | 54 +++- src/view/status.rs | 54 ++-- src/view/theme.rs | 7 +- src/view/workflow.rs | 126 +++++---- 141 files changed, 3420 insertions(+), 2172 deletions(-) create mode 100644 docs/CODEMAPS/architecture.md create mode 100644 docs/CODEMAPS/backend.md create mode 100644 docs/CODEMAPS/data.md create mode 100644 docs/CODEMAPS/dependencies.md create mode 100644 docs/CODEMAPS/frontend.md delete mode 100644 src/app/runtime/stream/tools/mod.rs diff --git a/docs/CODEMAPS/architecture.md b/docs/CODEMAPS/architecture.md new file mode 100644 index 0000000..f36a186 --- /dev/null +++ b/docs/CODEMAPS/architecture.md @@ -0,0 +1,65 @@ +# Architecture Overview + +## System Layout + +Zesdex is an autonomous AI coding agent with a TUI — an LLM client wrapped in a tool-use harness with 37 built-in tools. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Process Mode │ +│ Single-Process ─── Daemon (background) ─── Attach (client) │ +└──────────────────────────┬──────────────────────────────────┘ + │ IPC (Unix domain socket) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ src/main.rs │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ Controller │──▶│ Runtime │──▶│ View │ │ +│ │ (input.rs) │ │ (actions.rs) │ │ (chat,status,…)│ │ +│ └──────────────┘ └──────┬───────┘ └────────────────┘ │ +│ │ │ +│ ┌───────▼────────┐ │ +│ │ Harness │ │ +│ │ (tool dispatch)│ │ +│ └───────┬────────┘ │ +│ │ │ +│ ┌─────────────────┼─────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌────────────┐ ┌───────────────┐ │ +│ │ Tools │ │ Subagents │ │ Workflow │ │ +│ │ (37x) │ │ (auto/gen) │ │ Engine │ │ +│ └─────────┘ └────────────┘ │ (hive_mind) │ │ +│ └───────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Process Modes + +| Mode | Description | +|------|-------------| +| **Single-process** | TUI + agent run in the same process. Simplest mode. | +| **Daemon** | `--daemon` flag. Agent processes state in background; clients attach to render. | +| **Attach** | `--attach ` flag. Connect to existing daemon with IPC. | + +In daemon mode, the daemon runs the full agent loop; clients are stateless renderers that sync via Unix domain sockets with diff-based state synchronization. + +## Data Flow + +1. **Input** → `controller/input.rs` handles key events and autocomplete +2. **Dispatch** → `app/runtime/actions/mod.rs` applies actions to state (`AppStateRest`) +3. **LLM Stream** → `app/runtime/stream/mod.rs` parses SSE chunks into typed events +4. **Tool Execution** → `app/harness.rs` gates and runs tool calls via the `Tool` trait +5. **Rendering** → `view/` modules read `AppStateRest` and render via ratatui + +## Key Files + +| File | Purpose | +|------|---------| +| `src/main.rs` | Entry point, process mode dispatch, TUI init | +| `src/app/state/rest.rs` | Single source-of-truth state struct | +| `src/app/runtime/actions/mod.rs` | State reducer (`apply_action`) | +| `src/app/runtime/stream/mod.rs` | SSE stream parser | +| `src/app/harness.rs` | Tool harness with safety gating | +| `src/app/workflow/hive_mind.rs` | Multi-agent orchestration | +| `src/tool/mod.rs` | Tool trait + registry (37 tools) | +| `src/view/mod.rs` | TUI render pipeline | diff --git a/docs/CODEMAPS/backend.md b/docs/CODEMAPS/backend.md new file mode 100644 index 0000000..c6a4ec9 --- /dev/null +++ b/docs/CODEMAPS/backend.md @@ -0,0 +1,68 @@ +# Backend Architecture + +## Provider Layer + +The provider abstraction in `dto/provider/` and `service/provider.rs` wraps LLM API calls: + +- **Configuration**: `model/app_config.rs` loads Anthropic/OpenAI-compatible endpoint settings +- **Authentication**: `service/oauth/` handles OAuth 2.0 with PKCE flow and token management +- **Requests**: `dto/provider/request.rs` builds provider-agnostic request structs +- **Responses**: `dto/provider/response.rs` parses streaming and non-streaming responses +- **Token tracking**: `dto/provider/usage.rs` tracks token consumption + +## IPC (Inter-Process Communication) + +The daemon-client protocol in `src/ipc/`: + +- **Transport**: Unix domain sockets +- **Framing**: Length-prefixed frames with `serde_json` serialization (`ipc/frame.rs`) +- **State Sync**: Full state push from daemon after each action (`ipc/snapshot.rs`); diff-based updates for efficiency (`ipc/diff.rs`) +- **Protocol**: `ipc/protocol.rs` defines message types (Action, StateSnapshot, etc.) + +Flow: +``` +Client ──Action──▶ Daemon ──apply_action()──▶ State mutated + │ + └──StatePayload──▶ Client (render) +``` + +## Workflow Engine + +Located in `src/app/workflow/`: + +- **Script DSL** (`engine.rs`): Executes the workflow script language (agent/parallel/pipeline/phase). Supports subagent spawning with schema-validated output, concurrency limiting, and budget tracking. +- **Hive Mind** (`hive_mind.rs`): Core Intelligence spawns a CognitiveCyclePlan — ordered cycles of parallel processing nodes. Each node has a directive and access tier (`read`/`write`/`full`). Node outputs merge into a shared collective state in real time. Final consensus synthesis completes the convergence. +- **Docs** (`docs.rs`): Deterministic (not LLM) convergence writer — records every node's output + final consensus to `docs/runs/`. + +## MCP (Model Context Protocol) + +`src/app/mcp/manager.rs` manages MCP client connections: + +- Uses the `rmcp` crate for the MCP protocol +- Supports stdio-based transport (child process) and streamable HTTP +- Tool discovery via `list_tools()` and dynamic tool registration + +## LSP Integration + +`src/app/lsp/` provides Language Server Protocol support: + +- **Auto-provisioner** (`provisioner.rs`): Detects and starts LSP servers for Rust, TypeScript, Python, Go, and other languages +- **Client** (`client.rs`): JSON-RPC-based LSP client with typed notifications +- **Tools** (`tool/lsp/mod.rs`): 7 LSP tools (connect, hover, completion, definition, references, diagnostics, disconnect) + +## Background Bash + +`src/app/bgbash/` manages long-running shell jobs: + +- **Control** (`control.rs`): Job lifecycle management (spawn, signal, terminate) using Unix process groups +- **Job** (`job.rs`): Individual job state tracking with output buffering and progress monitoring + +## Review System + +`src/app/subagent/auto.rs` spawns background reviews: + +- Quick review after every edit +- Background test generation +- Architecture review +- Security review +- All retry once on failure, escalate to blocking error if retry also fails diff --git a/docs/CODEMAPS/data.md b/docs/CODEMAPS/data.md new file mode 100644 index 0000000..362ef7e --- /dev/null +++ b/docs/CODEMAPS/data.md @@ -0,0 +1,89 @@ +# Data Architecture + +## State Model + +The single source of truth is `AppStateRest` (`src/app/state/rest.rs`): + +``` +AppStateRest +├── session: SessionRuntime (hive_mind state, convergence flag) +├── runtime: RuntimeState (mode, provider status) +├── chat: ChatState (messages, scroll) +├── input: InputState (text, cursor, autocomplete) +├── settings: Settings (provider, model, temperature, concise_output) +├── config: AppConfig (endpoints, credentials) +├── scroll: ScrollState (per-panel offset) +├── diff: DiffState (edit review) +├── tools: Vec with outputs +├── statusline, sidebar, etc. +└── toasts: pending notifications +``` + +**Mutation rules** (per CLAUDE.md): +- Mutated in-place from exactly two locations: `actions/mod.rs` (apply_action) and `controller/input.rs` (key handlers) +- Read-only from every other module +- No generic update function — direct field mutation only + +## Persistence + +### SQLite Message Log (`src/model/msglog/`) + +| File | Purpose | +|------|---------| +| `schema.rs` | Table definitions (messages, sessions) | +| `mod.rs` | CRUD operations | +| `query.rs` | Query helpers (search, filter) | +| `blobs.rs` | Large message blob storage | +| `summary.rs` | Conversation summary cache | + +Schema uses `rusqlite` (bundled) with per-session isolation — each session gets its own database. + +### Memory System (`src/model/memory.rs`) + +File-based memory stored under `~/.claude/projects//memory/`: + +- Each memory is one markdown file with frontmatter (name, description, type) +- Types: `user`, `feedback`, `project`, `reference` +- Memory index in MEMORY.md +- Export/import for lesson sharing +- PID-file session lock prevents concurrent access + +### Settings & Config (`src/model/`) + +| File | Purpose | +|------|---------| +| `settings.rs` | Serialized user preferences (provider, model, theme) | +| `app_config.rs` | Provider endpoints, API key resolution from env | +| `session.rs` | Current session metadata | +| `conversation.rs` | In-memory conversation state | +| `editlog.rs` | Append-only JSONL edit audit trail | + +### Edit Log + +`src/model/editlog.rs` records every file mutation: + +```json +{"ts": 123, "tool": "edit", "path": "src/main.rs", + "reason": "fix bug", "content_sha256": "abc123", + "bytes_delta": 15, "origin": "chat", "session_id": "sess-1"} +``` + +Max 5000 entries held in memory before pruning oldest. + +## Context Management (`src/app/runtime/context/`) + +| Module | Purpose | +|--------|---------| +| `tokens.rs` | Token counting via `tiktoken-rs` | +| `window.rs` | Token window resolution (fit within model context) | +| `dedup.rs` | Deduplication of repeated tool outputs | +| `squash.rs` | Compression of large JSON tool results | +| `shaping.rs` | Message dropping when context exceeds limits | + +## IPC Data Flow + +``` +Daemon State ──diff──▶ serialize ──frame──▶ socket ──▶ Client + │ +Client State ◀── apply_diff ◀── deserialize ◀──── socket ─┘ +``` diff --git a/docs/CODEMAPS/dependencies.md b/docs/CODEMAPS/dependencies.md new file mode 100644 index 0000000..d2d27bd --- /dev/null +++ b/docs/CODEMAPS/dependencies.md @@ -0,0 +1,99 @@ +# Dependencies + +## Rust Crates (30+ direct) + +### Core Framework +| Crate | Version | Purpose | +|-------|---------|---------| +| `ratatui` | 0.30.2 | TUI framework | +| `crossterm` | 0.29 | Terminal manipulation | +| `tokio` | 1 | Async runtime (multi-thread, macros, sync, time, net, io-util, signal) | + +### HTTP & Networking +| Crate | Version | Purpose | +|-------|---------|---------| +| `reqwest` | 0.13 | HTTP client (JSON, streaming, native-tls-vendored, form) | +| `rmcp` | 2.2 | MCP client (child-process, streamable HTTP) | +| `webbrowser` | 1 | Open URLs in browser | +| `url` | 2 | URL parsing | +| `percent-encoding` | 2 | URL encoding | + +### HTML/Markdown +| Crate | Version | Purpose | +|-------|---------|---------| +| `dom_smoothie` | 0.18.0 | HTML DOM manipulation | +| `fast_html2md` | 0.0.62 | HTML-to-Markdown conversion | +| `scraper` | 0.27.0 | HTML parsing/selecting | +| `pulldown-cmark` | 0.13 | Markdown parsing (no default features) | + +### Serialization +| Crate | Version | Purpose | +|-------|---------|---------| +| `serde` | 1 | Serialization framework | +| `serde_json` | 1 | JSON serialization | +| `serde_yaml_ng` | 0.10 | YAML serialization | + +### Storage & Files +| Crate | Version | Purpose | +|-------|---------|---------| +| `rusqlite` | 0.40 | SQLite (bundled) | +| `ignore` | 0.4 | `.gitignore`-aware file walking | +| `globset` | 0.4 | Glob pattern matching | +| `include_dir` | 0.7 | Embed directory contents in binary | +| `infer` | 0.19 | File type detection | +| `dirs` | 6 | Standard OS directories | + +### Text & Search +| Crate | Version | Purpose | +|-------|---------|---------| +| `regex` | 1 | Regular expressions | +| `nucleo-matcher` | 0.3 | Fuzzy matching (for @mention autocomplete) | +| `similar` | 3 | Diff computation | +| `syntect` | 5 | Syntax highlighting | +| `tiktoken-rs` | 0.12 | OpenAI token counting | + +### Cryptography & Encoding +| Crate | Version | Purpose | +|-------|---------|---------| +| `base64` | 0.22 | Base64 encoding | +| `sha2` | 0.11 | SHA-256 hashing | +| `hex` | 0.4 | Hex encoding | +| `uuid` | 1 | UUID generation (v4, v5) | +| `libc` | 0.2 | Raw C FFI bindings | + +### Error Handling & Logging +| Crate | Version | Purpose | +|-------|---------|---------| +| `anyhow` | 1 | Error handling | +| `tracing` | 0.1 | Structured logging | +| `tracing-subscriber` | 0.3 | Log subscriber with env-filter | +| `chrono` | 0.4 | Date/time with serde | + +### Other +| Crate | Version | Purpose | +|-------|---------|---------| +| `lsp-types` | 0.97 | LSP protocol types | +| `futures-util` | 0.3 | Async stream combinators | + +## External Services + +| Service | Purpose | +|---------|---------| +| **Anthropic API** | Primary LLM provider | +| **OpenAI API** | Alternative LLM provider (including OAuth) | +| **GitHub** | Release artifacts via semantic-release CI | +| **MCP Servers** | External tool servers (stdio or HTTP) | +| **LSP Servers** | Language servers (rust-analyzer, TypeScript, Pyright, gopls, etc.) | + +## Build Configuration + +### Compiler Lints (`.cargo/config.toml`) +All unused code, dead code, and deprecation warnings promoted to errors: +`-W unused`, `-W dead_code`, `-W unreachable_code`, `-D warnings` + +### Release Profile +`opt-level=3`, LTO="fat", `codegen-units=1`, `panic="abort"`, `strip="symbols"`, `overflow-checks=true` + +### CI/CD +- **CI**: cargo build + test + clippy on every push +- **Release**: semantic-release with changelog generation, Cargo.toml version bump, GitHub artifact upload diff --git a/docs/CODEMAPS/frontend.md b/docs/CODEMAPS/frontend.md new file mode 100644 index 0000000..911af07 --- /dev/null +++ b/docs/CODEMAPS/frontend.md @@ -0,0 +1,79 @@ +# Frontend (TUI) Architecture + +## Render Pipeline + +The TUI is built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm). + +``` +Timer tick + │ + ▼ +main.rs: fn tui_loop() + │ + ├── controller/input.rs: handle_key() → action + ├── app/runtime/actions/mod.rs: apply_action() + │ │ + │ └── state mutates (AppStateRest) + │ + └── view/mod.rs: build TUI layout + │ + ├── view/chat.rs: Chat transcript + ├── view/sidebar.rs: Usage dashboard + ├── view/status.rs: Status bar + ├── view/markdown.rs: Message renderer + ├── view/workflow.rs: Hive-mind progress + └── view/theme.rs: Tokyo Night palette +``` + +## Overlay System + +16 overlays managed by `app/mode/`: + +| Overlay | File | Purpose | +|---------|------|---------| +| Chat input | `mod.rs` | Main input bar with autocomplete | +| Bash | `bash.rs` | Interactive shell panel | +| Editor | `editor.rs` | Built-in file editor | +| Effort | `effort.rs` | LLM effort selector | +| Help | `help.rs` | Keybindings help | +| Key Input | `key_input.rs` | Custom key binding | +| Learning | `learning.rs` | Lesson viewer | +| Loading | `loading.rs` | Spinner overlay | +| MCP | `mcp.rs` | MCP server management | +| Quit Confirm | `quit_confirm.rs` | Exit confirmation dialog | +| Rewind | `rewind.rs` | Message/history rewind | +| Settings | `settings.rs` | Settings panel | +| Todo | `todo.rs` | Task/TODO list | +| Workflow | (via view) | Workflow progress | + +## Layout Structure + +``` +┌─────────────────────────────────────────────┐ +│ Status Bar (view/status.rs) │ +├──────────────────────┬──────────────────────┤ +│ │ │ +│ Chat Transcript │ Sidebar │ +│ (view/chat.rs) │ (view/sidebar.rs) │ +│ scrollable, │ tokens, status, │ +│ inline-log style │ agent info │ +│ │ │ +├──────────────────────┴──────────────────────┤ +│ Input Bar + Autocomplete dropdown │ +│ (view/mod.rs) │ +└─────────────────────────────────────────────┘ +``` + +## Input Handling + +`controller/input.rs`: + +- Normal mode: keystrokes go to the active overlay +- `@mention` triggers fuzzy autocomplete (via `nucleo-matcher`) +- Tab cycles autocomplete candidates +- `Ctrl+Y` copies selected text to clipboard (via OSC52 escape sequence) +- Arrow keys scroll chat, sidebar, and other scrollable panels + +## Theme + +`view/theme.rs` defines a Tokyo Night color palette as constants (`Theme::PRIMARY`, `Theme::ERROR`, `Theme::TEXT_MUTED`, etc.) rather than using a theme enum or hot-reloadable config. All view modules import and apply these constants directly. diff --git a/src/app/bgbash/control.rs b/src/app/bgbash/control.rs index ba1f953..6eedde8 100644 --- a/src/app/bgbash/control.rs +++ b/src/app/bgbash/control.rs @@ -1,4 +1,9 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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 //! (output polling, kill) exposed to the rest of the app. //! @@ -10,7 +15,6 @@ //! Why: a single static map (rather than storing jobs in `AppStateRest`) //! lets background jobs outlive the borrow of any particular state mutation //! and be looked up by id from tool calls issued at arbitrary points. - use std::collections::HashMap; use std::sync::Mutex; use std::sync::OnceLock; @@ -44,7 +48,11 @@ pub fn bash_output(id: &str) -> Option> { while let Some(line) = job.try_read_line() { lines.push(line); } - if lines.is_empty() { None } else { Some(lines) } + if lines.is_empty() { + None + } else { + Some(lines) + } } /// Terminate a running background bash job and remove it from the registry. @@ -58,7 +66,9 @@ pub fn bash_output(id: &str) -> Option> { /// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job /// with that id exists. 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); match job { Some(job) => { diff --git a/src/app/bgbash/job.rs b/src/app/bgbash/job.rs index 9205139..c797fdd 100644 --- a/src/app/bgbash/job.rs +++ b/src/app/bgbash/job.rs @@ -9,11 +9,10 @@ //! Why: running bash commands on a detached thread with a channel (rather //! than synchronously) lets the TUI stay responsive while long-running //! shell commands execute in the background. - +use std::io::BufRead; use std::process::{Command, Stdio}; use std::sync::mpsc; use std::thread; -use std::io::BufRead; /// Maximum number of output lines buffered in memory per background job. /// Beyond this limit, old output is dropped to prevent OOM (CWE-770). @@ -59,17 +58,23 @@ pub fn spawn_bash_job(command: String) -> BashJob { // Spawn a named thread for easier debugging. If Builder::spawn fails // (e.g. OS resource limit), fall back to unnameable thread::spawn. let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]); - if thread::Builder::new().name(thread_name).spawn({ - // Clone everything the closure captures so we can also pass it - // to the fallback thread without moving. - let cmd = cmd.clone(); - let output_tx = output_tx.clone(); - let pid_tx = pid_tx.clone(); - let id_for_log = id_for_log.clone(); - move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log) - }).is_err() + if thread::Builder::new() + .name(thread_name) + .spawn({ + // Clone everything the closure captures so we can also pass it + // to the fallback thread without moving. + let cmd = cmd.clone(); + let output_tx = output_tx.clone(); + let pid_tx = pid_tx.clone(); + let id_for_log = id_for_log.clone(); + move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log) + }) + .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 || { spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log); }); @@ -139,7 +144,8 @@ fn spawn_bash_thread_body( if output_tx.try_send(line).is_err() { tracing::debug!( "[bgbash:{}] output buffer full ({} lines), discarding remaining output", - id_for_log, MAX_OUTPUT_LINES, + id_for_log, + MAX_OUTPUT_LINES, ); break; } diff --git a/src/app/bgbash/mod.rs b/src/app/bgbash/mod.rs index e411786..1f11b17 100644 --- a/src/app/bgbash/mod.rs +++ b/src/app/bgbash/mod.rs @@ -1,5 +1,4 @@ //! Background bash: run shell commands off the main thread, poll their //! output non-blockingly, and terminate them on demand. - pub mod control; pub mod job; diff --git a/src/app/harness.rs b/src/app/harness.rs index 83c0b8b..8a4e1b0 100644 --- a/src/app/harness.rs +++ b/src/app/harness.rs @@ -84,10 +84,17 @@ const ASSUMPTION_PATTERNS: &[&str] = &[ /// Network-exfiltration and credential-disclosure patterns for bash. const EXFIL_PATTERNS: &[&str] = &[ - "curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/", - "base64 -d |", "base64 --decode |", - "openssl s_client", "ssh -R ", - "scp /", "rsync /", + "curl ", + "wget ", + "nc -e ", + "ncat ", + "/dev/tcp/", + "base64 -d |", + "base64 --decode |", + "openssl s_client", + "ssh -R ", + "scp /", + "rsync /", ]; /// Substrings of well-known credential / secret files that bash must not read. @@ -115,169 +122,63 @@ impl Harness { /// Decide whether a tool call is allowed to execute. /// /// Flow: ALL tools are gated (not just risky ones), closing the bypass - /// for MCP tools (which are never in the risky list). Basic path - /// traversal and reason validation applies to any tool with a `path` - /// argument. Heavy content scanning (stub/denial/assumption/exfiltration) - /// only applies to risky tools. MCP tools (mcp__ prefix) are treated - /// as risky because their behaviour is unknown. + /// for MCP tools (which are never in the risky list). Delegates to + /// smaller helper methods for each concern: path traversal, output + /// path validation, content scanning, bash safety, and reason checks. /// /// Return: `Verdict::Allow` or `Verdict::Block(reason)`. - #[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)] pub fn gate_tool_call( tool_name: &str, args: &serde_json::Value, workspace_roots: &[&std::path::Path], ) -> Verdict { - let is_risky = crate::tool::tool_is_risky(tool_name); let is_mcp = tool_name.starts_with("mcp__"); - // ── Universal checks applied to EVERY tool ── - - // Path traversal: check ANY tool that accepts a path argument, - // not just write/edit/delete, so tools like read, MCP tools, - // and future tools are also protected. - if let Some(path) = args.get("path").and_then(|v| v.as_str()) { - if path.contains("..") { - return Verdict::Block( - "path traversal detected in 'path' argument".to_string(), - ); - } - if !workspace_roots.is_empty() { - let abs_check = std::path::PathBuf::from(path); - if abs_check.is_absolute() - && !workspace_roots.iter().any(|r| abs_check.starts_with(r)) - { - return Verdict::Block(format!( - "absolute path '{path}' is outside all workspace roots" - )); - } - } + // Universal checks applied to EVERY tool. + if let Some(v) = Self::check_path_traversal(args, workspace_roots) { + return v; + } + if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) { + return v; } - // Workspace-root validation for output path. - if let Some(out_path) = Self::find_output_path(tool_name, args) { - if !workspace_roots.is_empty() - && !out_path.starts_with("/tmp") - && !out_path.is_absolute() - { - let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r)); - if !allowed { - return Verdict::Block(format!( - "output path '{out_path:?}' is outside all workspace roots" - )); - } - } - } - - // ── Risky / MCP tool checks ── - // Non-risky, non-MCP tools (read, grep, glob, recall, etc.) are - // allowed after universal checks above. + // Non-risky, non-MCP tools pass after universal checks. if !is_risky && !is_mcp { return Verdict::Allow; } - // File-mutating tools: write / edit / delete + // File-mutating tools: require a meaningful reason. if matches!(tool_name, "write" | "edit" | "delete") { - match Self::validate_reason(tool_name, args) { - Ok(()) => {} - Err(msg) => return Verdict::Block(msg), + if let Err(msg) = Self::validate_reason(tool_name, args) { + return Verdict::Block(msg); } } - // write / edit content must not contain stubs, denial language, or - // assumption language. - if matches!(tool_name, "write" | "edit") { - if let Some(content) = Self::extract_content(tool_name, args) { - if let Some(pat) = Self::first_match(&content, STUB_PATTERNS) { - return Verdict::Block(format!( - "content contains stub/placeholder pattern '{pat}'; \ - production code must be fully implemented — \ - replace the stub with a real implementation" - )); - } - if let Some(pat) = Self::first_match(&content, DENIAL_PATTERNS) { - return Verdict::Block(format!( - "content contains denial/punt pattern '{pat}'; \ - implement the change properly instead of skipping" - )); - } - if let Some(pat) = Self::first_match(&content, ASSUMPTION_PATTERNS) { - return Verdict::Block(format!( - "content contains assumption pattern '{pat}'; \ - verify against data/tests instead of guessing" - )); - } - } + // write / edit content scanning for stub/denial/assumption patterns. + if let Some(v) = Self::check_content_safety(tool_name, args) { + return v; } - // Bash: destructive patterns, exfiltration (ALL commands checked, - // no safe-command whitelist), sensitive-path reads. - if tool_name == "bash" { - let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or(""); - if cmd.contains("..") { - return Verdict::Block( - "path traversal detected in bash command".to_string(), - ); - } - // Exfiltration patterns are checked on EVERY bash command, - // regardless of prefix. The safe-command whitelist was removed - // because it could be bypassed with command chaining. - for pat in EXFIL_PATTERNS { - if cmd.contains(pat) { - return Verdict::Block(format!( - "potential data-exfiltration command blocked (matched '{pat}')" - )); - } - } - for pat in SENSITIVE_PATH_PATTERNS { - if cmd.contains(pat) { - return Verdict::Block(format!( - "refused to read/write sensitive path '{pat}'" - )); - } - } - let dangerous_patterns = [ - "rm -rf /", "rm -rf --no-preserve-root", - "rm -rf ~", "rm -fr /", "mkfs.", "dd if=", - ":(){", "> /dev/sda", "chmod -R 000 /", - "shutdown ", "poweroff ", "reboot ", "halt ", - ]; - for pat in &dangerous_patterns { - if cmd.contains(pat) { - return Verdict::Block(format!( - "destructive command pattern blocked: {pat}" - )); - } - } - // Also scan heredocs / -c / inline content for stub/denial - // language (e.g. `bash -c 'echo todo!()'`) - if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) { + // Bash-specific destructive / exfiltration checks. + if let Some(v) = Self::check_bash_safety(args) { + return v; + } + + // git_operator: require a non-trivial reason. + if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) { + if args.get("reason").and_then(|v| v.as_str()).is_some() { return Verdict::Block(format!( - "bash command contains stub pattern '{pat}'" + "git_operator requires a non-trivial 'reason' \ + (>= {MIN_REASON_LEN} chars) explaining the operation" )); } + return Verdict::Block( + "git_operator requires a 'reason' argument explaining the operation".to_string(), + ); } - // git_operator: require a non-trivial reason as well. - if tool_name == "git_operator" { - if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) { - if reason.trim().len() < MIN_REASON_LEN { - return Verdict::Block(format!( - "git_operator requires a non-trivial 'reason' \ - (>= {MIN_REASON_LEN} chars) explaining the operation" - )); - } - } else { - return Verdict::Block( - "git_operator requires a 'reason' argument explaining the operation" - .to_string(), - ); - } - } - - // MCP tools: unknown behaviour — require a reason if they take - // arguments, to discourage lazy invocations. + // MCP tools: require a reason when they take meaningful arguments. if is_mcp { if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) { if reason.trim().len() < MIN_REASON_LEN { @@ -287,7 +188,6 @@ impl Harness { )); } } else if args.as_object().is_some_and(|m| !m.is_empty()) { - // Only require reason when there are meaningful arguments return Verdict::Block(format!( "MCP tool '{tool_name}' requires a 'reason' argument \ explaining the operation" @@ -298,6 +198,163 @@ impl Harness { Verdict::Allow } + /// Check for path traversal in the `path` argument and verify it stays + /// within workspace roots. + /// + /// Flow: reject any path containing `..` → if workspace roots are set, + /// reject absolute paths outside every root. + /// + /// Return: `Some(Verdict::Block)` on violation, `None` if the check + /// passes or the tool has no `path` argument. + fn check_path_traversal( + args: &serde_json::Value, + workspace_roots: &[&std::path::Path], + ) -> Option { + let path = args.get("path")?.as_str()?; + if path.contains("..") { + return Some(Verdict::Block( + "path traversal detected in 'path' argument".to_string(), + )); + } + if !workspace_roots.is_empty() { + let abs_check = std::path::PathBuf::from(path); + if abs_check.is_absolute() && !workspace_roots.iter().any(|r| abs_check.starts_with(r)) + { + return Some(Verdict::Block(format!( + "absolute path '{path}' is outside all workspace roots" + ))); + } + } + None + } + + /// Verify that a tool's output path (if any) stays within workspace roots. + /// + /// Flow: if `find_output_path` yields a path, reject it unless it's + /// under `/tmp`, already absolute, or within a workspace root. + /// + /// Return: `Some(Verdict::Block)` on violation, `None` otherwise. + fn check_output_path( + tool_name: &str, + args: &serde_json::Value, + workspace_roots: &[&std::path::Path], + ) -> Option { + let out_path = Self::find_output_path(tool_name, args)?; + if !workspace_roots.is_empty() && !out_path.starts_with("/tmp") && !out_path.is_absolute() { + let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r)); + if !allowed { + return Some(Verdict::Block(format!( + "output path '{}' is outside all workspace roots", + out_path.display(), + ))); + } + } + None + } + + /// Check write/edit content for stub, denial, and assumption patterns. + /// + /// Return: `Some(Verdict::Block)` with a description of the first + /// matched pattern, `None` if the content is clean or not applicable. + fn check_content_safety(tool_name: &str, args: &serde_json::Value) -> Option { + if !matches!(tool_name, "write" | "edit") { + return None; + } + let content = Self::extract_content(tool_name, args)?; + for (patterns, msg_prefix) in [ + (&STUB_PATTERNS, "stub/placeholder"), + (&DENIAL_PATTERNS, "denial/punt"), + (&ASSUMPTION_PATTERNS, "assumption"), + ] { + if let Some(pat) = Self::first_match(&content, patterns) { + let msg = match msg_prefix { + "stub/placeholder" => format!( + "content contains stub/placeholder pattern '{pat}'; \ + production code must be fully implemented — \ + replace the stub with a real implementation" + ), + "denial/punt" => format!( + "content contains denial/punt pattern '{pat}'; \ + implement the change properly instead of skipping" + ), + _ => format!( + "content contains assumption pattern '{pat}'; \ + verify against data/tests instead of guessing" + ), + }; + return Some(Verdict::Block(msg)); + } + } + None + } + + /// Check bash commands for path traversal, exfiltration, sensitive + /// path reads, destructive patterns, and stub language. + /// + /// Flow: extract the `command` argument → check each category in + /// sequence, returning the first violation found. + /// + /// Return: `Some(Verdict::Block)` on any violation, `None` if the + /// tool is not bash or the command is safe. + fn check_bash_safety(args: &serde_json::Value) -> Option { + let cmd = args.get("command")?.as_str()?; + if cmd.contains("..") { + return Some(Verdict::Block( + "path traversal detected in bash command".to_string(), + )); + } + for pat in EXFIL_PATTERNS { + if cmd.contains(pat) { + return Some(Verdict::Block(format!( + "potential data-exfiltration command blocked (matched '{pat}')" + ))); + } + } + for pat in SENSITIVE_PATH_PATTERNS { + if cmd.contains(pat) { + return Some(Verdict::Block(format!( + "refused to read/write sensitive path '{pat}'" + ))); + } + } + let dangerous_patterns = [ + "rm -rf /", + "rm -rf --no-preserve-root", + "rm -rf ~", + "rm -fr /", + "mkfs.", + "dd if=", + ":(){", + "> /dev/sda", + "chmod -R 000 /", + "shutdown ", + "poweroff ", + "reboot ", + "halt ", + ]; + for pat in &dangerous_patterns { + if cmd.contains(pat) { + return Some(Verdict::Block(format!( + "destructive command pattern blocked: {pat}" + ))); + } + } + if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) { + return Some(Verdict::Block(format!( + "bash command contains stub pattern '{pat}'" + ))); + } + None + } + + /// Check whether the given `args` contain a non-trivial `reason` + /// argument meeting the minimum length requirement. + fn has_valid_reason(args: &serde_json::Value, min_len: usize) -> bool { + args.get("reason") + .and_then(|v| v.as_str()) + .is_some_and(|r| r.trim().len() >= min_len) + } + /// Validate the `reason` argument for a mutating tool. /// /// Flow: require the field to exist and be a non-empty string ≥ @@ -317,17 +374,13 @@ impl Harness { Some(v) => match v.as_str() { Some(s) => s, None => { - return Err(format!( - "{tool_name} 'reason' must be a string" - )); + return Err(format!("{tool_name} 'reason' must be a string")); } }, }; let trimmed = reason.trim(); if trimmed.is_empty() { - return Err(format!( - "{tool_name} 'reason' must not be empty" - )); + return Err(format!("{tool_name} 'reason' must not be empty")); } if trimmed.len() < MIN_REASON_LEN { return Err(format!( @@ -339,9 +392,20 @@ impl Harness { // Reject generic non-answers let lower = trimmed.to_lowercase(); let non_answers = [ - "fix", "update", "change", "edit", "modify", - "implement", "add", "remove", "delete", - "make it work", "make work", "test", "wip", "tbd", + "fix", + "update", + "change", + "edit", + "modify", + "implement", + "add", + "remove", + "delete", + "make it work", + "make work", + "test", + "wip", + "tbd", ]; if non_answers.iter().any(|n| lower == *n) { return Err(format!( @@ -356,7 +420,10 @@ impl Harness { /// Extract the textual content of a write/edit call, if any. fn extract_content(tool_name: &str, args: &serde_json::Value) -> Option { match tool_name { - "write" => args.get("content").and_then(|v| v.as_str()).map(String::from), + "write" => args + .get("content") + .and_then(|v| v.as_str()) + .map(String::from), "edit" => { 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(""); @@ -377,9 +444,10 @@ impl Harness { /// Extract a candidate output path from a tool call, if one exists. fn find_output_path(tool_name: &str, args: &serde_json::Value) -> Option { match tool_name { - "write" | "edit" | "delete" | "read" => { - args.get("path").and_then(|v| v.as_str()).map(std::path::PathBuf::from) - } + "write" | "edit" | "delete" | "read" => args + .get("path") + .and_then(|v| v.as_str()) + .map(std::path::PathBuf::from), "bash" => { let cmd = args.get("command").and_then(|v| v.as_str())?; let lower = cmd.to_lowercase(); @@ -397,7 +465,6 @@ impl Harness { _ => None, } } - } impl Default for Harness { @@ -418,7 +485,10 @@ mod tests { return match verdict.to_lowercase().as_str() { "allow" => Some(Verdict::Allow), "block" => Some(Verdict::Block( - v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string() + v.get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("blocked") + .to_string(), )), _ => None, }; @@ -430,7 +500,11 @@ mod tests { return Some(Verdict::Allow); } if l.starts_with("verdict: block") { - let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string(); + let reason = line + .split_once(':') + .map_or("blocked", |x| x.1) + .trim() + .to_string(); return Some(Verdict::Block(reason)); } } @@ -450,7 +524,6 @@ mod tests { assert_eq!(result, Verdict::Allow); } - #[test] fn test_parse_verdict_json_allow() { let v = parse_verdict(r#"{"verdict": "allow"}"#); diff --git a/src/app/lsp/client.rs b/src/app/lsp/client.rs index 37dbdeb..15ac237 100644 --- a/src/app/lsp/client.rs +++ b/src/app/lsp/client.rs @@ -47,13 +47,20 @@ impl LspClient { cmd.stdout(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}"))?; - let stdin = child.stdin.take() + let stdin = child + .stdin + .take() .ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?; - let stdout = BufReader::new(child.stdout.take() - .ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?); + let stdout = BufReader::new( + child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?, + ); let mut client = LspClient { stdin, @@ -106,7 +113,11 @@ 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.notify("initialized", &json!({}))?; @@ -122,7 +133,12 @@ impl LspClient { 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 { + fn call_with_timeout( + &mut self, + method: &str, + params: &Value, + timeout: Duration, + ) -> anyhow::Result { self.next_id += 1; let id = self.next_id; let req = json!({ @@ -148,11 +164,14 @@ impl LspClient { let body = serde_json::to_string(msg) .map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?; 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}"))?; - 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}"))?; - self.stdin.flush() + self.stdin + .flush() .map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?; Ok(()) } @@ -166,8 +185,14 @@ impl LspClient { let frame = self.read_frame()?; if frame.get("id") == Some(&json!(expected_id)) { if let Some(err) = frame.get("error") { - 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 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"); anyhow::bail!("LSP error {code}: {msg}"); } return Ok(frame.get("result").cloned().unwrap_or(Value::Null)); @@ -205,8 +230,9 @@ impl LspClient { // Cap Content-Length at 64 MiB to prevent OOM from a // malicious or misconfigured LSP server (CWE-400). const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024; - let length: usize = len_str.trim().parse::() - .map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?; + let length: usize = len_str.trim().parse::().map_err(|e| { + anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e) + })?; if length > MAX_CONTENT_LENGTH { anyhow::bail!( "Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes", @@ -220,7 +246,8 @@ impl LspClient { .ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?; 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 ({length} bytes): {e}"))?; let json_str = String::from_utf8(body) @@ -230,74 +257,98 @@ impl LspClient { .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<()> { - self.notify("textDocument/didOpen", &json!({ - "textDocument": { - "uri": uri, - "languageId": language_id, - "version": version, - "text": text - } - })) + pub fn did_open( + &mut self, + uri: &str, + language_id: &str, + version: i32, + text: &str, + ) -> anyhow::Result<()> { + self.notify( + "textDocument/didOpen", + &json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": version, + "text": text + } + }), + ) } - #[allow(dead_code)] pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> { - self.notify("textDocument/didChange", &json!({ - "textDocument": { - "uri": uri, - "version": version - }, - "contentChanges": [{ - "text": text - }] - })) + self.notify( + "textDocument/didChange", + &json!({ + "textDocument": { + "uri": uri, + "version": version + }, + "contentChanges": [{ + "text": text + }] + }), + ) } pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> { - self.notify("textDocument/didClose", &json!({ - "textDocument": { - "uri": uri - } - })) + self.notify( + "textDocument/didClose", + &json!({ + "textDocument": { + "uri": uri + } + }), + ) } pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call("textDocument/hover", &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - })) + self.call( + "textDocument/hover", + &json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + }), + ) } pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call("textDocument/completion", &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - })) + self.call( + "textDocument/completion", + &json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + }), + ) } - pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call("textDocument/definition", &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - })) + pub fn goto_definition( + &mut self, + uri: &str, + line: u32, + character: u32, + ) -> anyhow::Result { + self.call( + "textDocument/definition", + &json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + }), + ) } pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call("textDocument/references", &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character }, - "context": { - "includeDeclaration": true - } - })) - } - - #[allow(dead_code)] - pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result { - self.call("textDocument/documentSymbol", &json!({ - "textDocument": { "uri": uri } - })) + self.call( + "textDocument/references", + &json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + "context": { + "includeDeclaration": true + } + }), + ) } pub fn collect_diagnostics( @@ -313,64 +364,14 @@ impl LspClient { ); self.did_close(uri)?; match result { - Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))), + Ok(params) => Ok(params + .get("diagnostics") + .cloned() + .unwrap_or_else(|| json!([]))), Err(e) => Err(e), } } - /// Health-check the LSP server. - /// - /// Sends a `textDocument/documentSymbol` request on a dummy URI with a - /// 2-second timeout. Returns `true` if the server responds at all — - /// including with an error response such as "file not found", which - /// still proves the process is up and the JSON-RPC channel is live. - /// Returns `false` on timeout, EOF, or any read/write error. - /// - /// Flow: build request → `send_frame` → poll frames until id matches - /// (alive) or deadline/read error fires (dead). - #[allow(dead_code)] - pub fn is_alive(&mut self) -> bool { - self.next_id += 1; - let id = self.next_id; - let req = json!({ - "jsonrpc": "2.0", - "id": id, - "method": "textDocument/documentSymbol", - "params": { - "textDocument": { "uri": "file:///__zesdex_lsp_health_check__.txt" } - } - }); - if self.send_frame(&req).is_err() { - return false; - } - let timeout = Duration::from_secs(2); - let deadline = Instant::now() + timeout; - loop { - if Instant::now() > deadline { - return false; - } - match self.read_frame() { - Ok(frame) => { - if frame.get("id") == Some(&json!(id)) { - return true; - } - // Skip unrelated notifications/responses on the same channel. - } - Err(_) => return false, - } - } - } - - /// Send the LSP `exit` notification to request graceful shutdown. - /// - /// Per the LSP spec, `exit` is a notification — the server is expected - /// to terminate after receiving it without sending a response. We do - /// not block on any reply. - #[allow(dead_code)] - pub fn exit(&mut self) -> anyhow::Result<()> { - self.notify("exit", &json!({})) - } - pub fn shutdown(&mut self) { let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5)); let _ = self.notify("exit", &json!({})); diff --git a/src/app/lsp/mod.rs b/src/app/lsp/mod.rs index 0125ce6..2fe5255 100644 --- a/src/app/lsp/mod.rs +++ b/src/app/lsp/mod.rs @@ -13,12 +13,6 @@ pub use client::{path_to_lsp_uri, LspClient}; /// to issue LSP requests from threads or async tasks. #[derive(Clone)] pub struct LspServer { - #[allow(dead_code)] - pub name: String, - #[allow(dead_code)] - pub command: String, - #[allow(dead_code)] - pub args: Vec, pub language_id: String, pub client: Arc>, } @@ -38,12 +32,12 @@ pub struct OpenDoc { /// /// Flow: caller calls `connect*` -> client spawned -> entry pushed to /// `servers` -> `extension_registry` is populated by `register_extensions`. -/// File edits route through `find_server_for_path` / `find_server_for_extension` -/// and are dispatched as `didOpen` / `didChange` notifications. +/// File edits route through `extension_registry` and are dispatched as +/// `didOpen` / `didChange` notifications. #[derive(Clone)] pub struct LspManager { pub servers: Vec, - /// Maps file extension (".rs", ".ts", ...) -> server name. + /// Maps file extension (".rs", ".ts", ...) -> language id. pub extension_registry: HashMap, /// Maps document URI -> tracked open document state. pub open_files: HashMap, @@ -59,112 +53,73 @@ impl LspManager { } } - /// Spawn an LSP server and register it under `name`. + /// Spawn an LSP server and register it under `language_id`. /// - /// Fails if a server with the same name is already connected. + /// Fails if a server with the same `language_id` is already connected. pub fn connect( &mut self, - name: &str, command: &str, args: &[String], language_id: &str, ) -> anyhow::Result<()> { - if self.servers.iter().any(|s| s.name == name) { - anyhow::bail!("LSP server '{name}' is already connected"); + if self.servers.iter().any(|s| s.language_id == language_id) { + anyhow::bail!("LSP server for language '{language_id}' is already connected"); } let client = LspClient::spawn(command, args)?; self.servers.push(LspServer { - name: name.to_string(), - command: command.to_string(), - args: args.to_vec(), language_id: language_id.to_string(), client: Arc::new(Mutex::new(client)), }); Ok(()) } - /// Look up a connected server by name and return a reference to its entry. - #[allow(dead_code)] - pub fn find_server(&self, name: &str) -> Option<&LspServer> { - self.servers.iter().find(|s| s.name == name) - } - /// Return a clone of the `Arc>` for a connected server. /// /// Cloning the `Arc` lets callers issue requests without holding a /// borrow on the manager. - pub fn get_client(&self, name: &str) -> Option>> { - self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone()) + pub fn get_client(&self, language_id: &str) -> Option>> { + self.servers + .iter() + .find(|s| s.language_id == language_id) + .map(|s| s.client.clone()) } - /// Shut down and remove a server by name. Returns true if it existed. - pub fn disconnect(&mut self, name: &str) -> bool { - if let Some(server) = self.servers.iter().find(|s| s.name == name) { + /// Shut down and remove a server by language. Returns true if it existed. + pub fn disconnect(&mut self, language_id: &str) -> bool { + if let Some(server) = self.servers.iter().find(|s| s.language_id == language_id) { if let Ok(mut client) = server.client.lock() { client.shutdown(); } } let len = self.servers.len(); - self.servers.retain(|s| s.name != name); + self.servers.retain(|s| s.language_id != language_id); self.servers.len() < len } - /// Return the language id (e.g. "rust") registered for `name`. - pub fn get_language_id(&self, name: &str) -> Option { - self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone()) - } - - /// Resolve an extension (".rs", ".ts", ...) to its server's client. - /// - /// Flow: lookup `extension_registry` -> resolve server name -> clone client. - /// Returns `None` if no server has been registered for `ext`. - #[allow(dead_code)] - pub fn find_server_for_extension(&self, ext: &str) -> Option>> { - self.extension_registry - .get(ext) - .and_then(|name| self.get_client(name)) - } - - /// Resolve a file path to its server's client by extension. - /// - /// Flow: extract the extension from `path` -> delegate to - /// `find_server_for_extension`. Files without an extension or with - /// an unmapped extension return `None`. - #[allow(dead_code)] - pub fn find_server_for_path(&self, path: &Path) -> Option>> { - path.extension() - .and_then(|e| e.to_str()) - .map(|s| format!(".{s}")) - .and_then(|ext| self.find_server_for_extension(&ext)) + /// Return the language id (e.g. "rust") registered for `language_id`. + pub fn get_language_id(&self, language_id: &str) -> Option { + self.servers + .iter() + .find(|s| s.language_id == language_id) + .map(|s| s.language_id.clone()) } /// Register a set of file extensions for an already-connected server. /// - /// Flow: for each `ext`, write `server_name` into `extension_registry`. - /// Re-registration overwrites the previous target. Unknown server - /// names are accepted at this layer — caller must ensure `server_name` - /// is connected or will be connected later. - pub fn register_extensions(&mut self, server_name: &str, extensions: &[&str]) { + /// Flow: for each `ext`, write `language_id` into `extension_registry`. + /// Re-registration overwrites the previous target. Unknown language IDs + /// are accepted at this layer — caller must ensure a server for + /// `language_id` is connected or will be connected later. + pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) { for ext in extensions { - self.extension_registry.insert(ext.to_string(), server_name.to_string()); + self.extension_registry + .insert(ext.to_string(), language_id.to_string()); } } - /// Return the registered server name for a given language id. - /// - /// Flow: scan `servers` for the first entry whose `language_id` matches. - /// Used when callers have a language hint rather than a file path. - #[allow(dead_code)] - pub fn get_server_name(&self, language: &str) -> Option { - self.servers - .iter() - .find(|s| s.language_id == language) - .map(|s| s.name.clone()) - } - /// Notify the relevant LSP server that a file's contents have changed. /// - /// Flow: resolve server by extension -> read file contents -> + /// Flow: resolve language by extension from the registry -> read file contents -> /// either send `didOpen` (first time) or `didChange` (already tracked) /// -> update `open_files` with the new version. /// @@ -172,13 +127,20 @@ impl LspManager { /// error) are logged with `tracing::warn!` rather than propagated, /// so a stale notification cannot abort the calling flow. pub fn did_change_file(&mut self, path: &Path) { - let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else { + let Some(ext) = path + .extension() + .and_then(|e| e.to_str()) + .map(|s| format!(".{s}")) + else { tracing::warn!("did_change_file: path has no extension: {:?}", path); return; }; - let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else { - tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext); + let Some(language_id) = self.extension_registry.get(&ext).cloned() else { + tracing::warn!( + "did_change_file: no LSP server registered for extension '{}'", + ext + ); return; }; @@ -192,12 +154,8 @@ impl LspManager { } }; - let language_id = self - .get_language_id(&server_name) - .unwrap_or_else(|| "plaintext".to_string()); - - let Some(client) = self.get_client(&server_name) else { - tracing::warn!("did_change_file: server '{}' has no client", server_name); + let Some(client) = self.get_client(&language_id) else { + tracing::warn!("did_change_file: no client for language '{}'", language_id); return; }; @@ -210,7 +168,11 @@ impl LspManager { let mut client = match client.lock() { Ok(c) => c, Err(e) => { - tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e); + tracing::warn!( + "did_change_file: client mutex poisoned for '{}': {}", + language_id, + e + ); return; } }; @@ -224,7 +186,7 @@ impl LspManager { if let Err(e) = send_result { tracing::warn!( "did_change_file: failed to notify '{}' for {}: {}", - server_name, + language_id, uri, e ); @@ -238,24 +200,6 @@ impl LspManager { version: next_version, }, ); - - } - - /// Record that `server_name` has an open document at `uri`. - /// - /// Flow: insert/overwrite the `OpenDoc` entry in `open_files`. - /// Does not contact the LSP server — pure local bookkeeping. - #[allow(dead_code)] - pub fn track_open_doc(&mut self, server_name: &str, uri: &str, language: &str, version: i32) { - // server_name retained for future routing extensions; not stored today. - let _ = server_name; - self.open_files.insert( - uri.to_string(), - OpenDoc { - language: language.to_string(), - version, - }, - ); } /// Shut down every connected server and clear the server list. @@ -272,21 +216,20 @@ impl LspManager { self.servers.clear(); } - /// Snapshot the connected servers as `(name, language_id, has_open_docs)` triples. + /// Snapshot the connected servers as `(language_id, has_open_docs)` pairs. /// /// `has_open_docs` is true if any tracked `OpenDoc` was registered /// against this server's clients. Useful for status displays. - pub fn list_servers(&self) -> Vec<(String, String, bool)> { + pub fn list_servers(&self) -> Vec<(String, bool)> { self.servers .iter() .map(|s| { - let name = s.name.clone(); let lang = s.language_id.clone(); let has_open = self .open_files .values() .any(|d| d.language == s.language_id); - (name, lang, has_open) + (lang, has_open) }) .collect() } @@ -294,18 +237,17 @@ impl LspManager { /// Connect an LSP server and register its default extensions in one call. /// /// Flow: invoke `connect` -> on success, register `extensions` against - /// `name` in `extension_registry`. If `connect` fails, the registries + /// `language_id` in `extension_registry`. If `connect` fails, the registries /// are left untouched and the error is propagated. pub fn connect_with_extensions( &mut self, - name: &str, command: &str, args: &[String], language_id: &str, extensions: &[&str], ) -> anyhow::Result<()> { - self.connect(name, command, args, language_id)?; - self.register_extensions(name, extensions); + self.connect(command, args, language_id)?; + self.register_extensions(language_id, extensions); Ok(()) } } @@ -315,4 +257,3 @@ impl Default for LspManager { Self::new() } } - diff --git a/src/app/lsp/provisioner.rs b/src/app/lsp/provisioner.rs index 453df23..605c107 100644 --- a/src/app/lsp/provisioner.rs +++ b/src/app/lsp/provisioner.rs @@ -11,7 +11,6 @@ //! Each tier is a fallback for the previous, so we try the most //! user-friendly path first (rustup component, npm global, etc.) and //! only fall back to package managers or manual download if those fail. - use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; @@ -45,14 +44,11 @@ pub enum ProvisionResult { language: String, binary_path: String, }, - /// Every install tier failed — `manual_instructions` tells the user how - /// to install by hand. + /// Every install tier failed. Tells the user how to install by hand. Failed { language: String, server_name: String, reason: String, - #[allow(dead_code)] - manual_instructions: String, }, } @@ -97,28 +93,55 @@ pub struct InstallTier { pub args: Vec, } +/// Rust toolchain availability on the host PATH. +#[derive(Debug, Clone)] +pub struct RustToolchain { + pub has_rustup: bool, + pub has_cargo: bool, +} + +/// Web / scripting language toolchain availability. +#[derive(Debug, Clone)] +pub struct WebToolchain { + pub has_npm: bool, + pub has_go: bool, + pub has_java: bool, +} + +/// General-purpose platform utilities. +#[derive(Debug, Clone)] +pub struct PlatformUtils { + pub has_curl: bool, + pub has_tar: bool, +} + +/// Pacman and Brew package managers (Arch / macOS). +#[derive(Debug, Clone)] +pub struct PacmanBrew { + pub has_pacman: bool, + pub has_brew: bool, +} + +/// Apt and DNF package managers (Debian / Fedora). +#[derive(Debug, Clone)] +pub struct AptDnf { + pub has_apt: bool, + pub has_dnf: bool, +} + /// 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_with_progress()` call so we /// don't re-shell out for every server. `is_linux` / `is_macos` are /// computed at startup (compile time would also work, but keeping the /// shape uniform with the rest of the struct makes the call sites tidy). #[derive(Debug, Clone)] -#[allow(dead_code)] -#[allow(clippy::struct_excessive_bools)] pub struct EnvInfo { - pub has_rustup: bool, - pub has_npm: bool, - pub has_go: bool, - pub has_java: bool, - pub has_cargo: bool, - pub has_curl: bool, - pub has_wget: bool, - pub has_tar: bool, - pub has_pacman: bool, - pub has_apt: bool, - pub has_brew: bool, - pub has_dnf: bool, + pub rust: RustToolchain, + pub web: WebToolchain, + pub platform: PlatformUtils, + pub pacman_brew: PacmanBrew, + pub apt_dnf: AptDnf, pub is_linux: bool, pub is_macos: bool, } @@ -159,18 +182,27 @@ pub fn which(binary: &str) -> Option { /// this only ever runs on Unix-like targets. pub fn detect_env() -> EnvInfo { EnvInfo { - has_rustup: which("rustup").is_some(), - has_npm: which("npm").is_some(), - has_go: which("go").is_some(), - has_java: which("java").is_some(), - has_cargo: which("cargo").is_some(), - has_curl: which("curl").is_some(), - has_wget: which("wget").is_some(), - has_tar: which("tar").is_some(), - has_pacman: which("pacman").is_some(), - has_apt: which("apt").is_some() || which("apt-get").is_some(), - has_brew: which("brew").is_some(), - has_dnf: which("dnf").is_some(), + rust: RustToolchain { + has_rustup: which("rustup").is_some(), + has_cargo: which("cargo").is_some(), + }, + web: WebToolchain { + has_npm: which("npm").is_some(), + has_go: which("go").is_some(), + has_java: which("java").is_some(), + }, + platform: PlatformUtils { + has_curl: which("curl").is_some(), + has_tar: which("tar").is_some(), + }, + pacman_brew: PacmanBrew { + has_pacman: which("pacman").is_some(), + has_brew: which("brew").is_some(), + }, + apt_dnf: AptDnf { + has_apt: which("apt").is_some() || which("apt-get").is_some(), + has_dnf: which("dnf").is_some(), + }, is_linux: cfg!(target_os = "linux"), is_macos: cfg!(target_os = "macos"), } @@ -179,14 +211,13 @@ pub fn detect_env() -> EnvInfo { /// Return the static set of supported language servers. /// /// The order is significant: it determines provisioning order and -/// the order results appear in `provision_all()`. Tier 1 paths are +/// the order results appear in `provision_all_with_progress()`. Tier 1 paths are /// the canonical/idiomatic install for each ecosystem; later tiers /// are fallbacks for hosts that lack the primary tooling. /// /// Why hard-coded rather than loaded from settings: the set is small, /// changes rarely, and bundling it lets the provisioner run before any /// user config has been read (e.g. on first launch). -#[allow(clippy::too_many_lines)] pub fn supported_servers() -> Vec { vec![ LanguageServerDef { @@ -199,13 +230,22 @@ pub fn supported_servers() -> Vec { label: "rustup component".to_string(), requires: vec!["rustup".to_string()], command: "rustup".to_string(), - args: vec!["component".to_string(), "add".to_string(), "rust-analyzer".to_string()], + args: vec![ + "component".to_string(), + "add".to_string(), + "rust-analyzer".to_string(), + ], }, InstallTier { label: "pacman".to_string(), requires: vec!["pacman".to_string()], command: "pacman".to_string(), - args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "rust-analyzer".to_string()], + args: vec![ + "-S".to_string(), + "--noconfirm".to_string(), + "--needed".to_string(), + "rust-analyzer".to_string(), + ], }, InstallTier { label: "brew".to_string(), @@ -217,7 +257,11 @@ pub fn supported_servers() -> Vec { label: "cargo install".to_string(), requires: vec!["cargo".to_string()], command: "cargo".to_string(), - args: vec!["install".to_string(), "--locked".to_string(), "rust-analyzer".to_string()], + args: vec![ + "install".to_string(), + "--locked".to_string(), + "rust-analyzer".to_string(), + ], }, InstallTier { label: "download prebuilt".to_string(), @@ -268,19 +312,33 @@ pub fn supported_servers() -> Vec { name: "jdtls".to_string(), language: "java".to_string(), extensions: vec![".java".to_string()], - binary_names: vec!["jdtls".to_string(), "eclipse-jdt-ls".to_string(), "jdtls-launcher".to_string()], + binary_names: vec![ + "jdtls".to_string(), + "eclipse-jdt-ls".to_string(), + "jdtls-launcher".to_string(), + ], install_tiers: vec![ InstallTier { label: "pacman".to_string(), requires: vec!["java".to_string(), "pacman".to_string()], command: "pacman".to_string(), - args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "eclipse-jdt-ls".to_string()], + args: vec![ + "-S".to_string(), + "--noconfirm".to_string(), + "--needed".to_string(), + "eclipse-jdt-ls".to_string(), + ], }, InstallTier { label: "apt".to_string(), requires: vec!["java".to_string(), "apt".to_string()], command: "sudo".to_string(), - args: vec!["apt".to_string(), "install".to_string(), "-y".to_string(), "eclipse-jdt-ls".to_string()], + args: vec![ + "apt".to_string(), + "install".to_string(), + "-y".to_string(), + "eclipse-jdt-ls".to_string(), + ], }, InstallTier { label: "brew".to_string(), @@ -339,7 +397,9 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> let timeout = Duration::from_mins(3); let start = Instant::now(); let status = loop { - if let Some(status) = child.try_wait()? { break Ok(status) } + if let Some(status) = child.try_wait()? { + break Ok(status); + } if start.elapsed() > timeout { let _ = child.kill(); let _ = child.wait(); @@ -405,9 +465,12 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> { info!(url = url, dest = %path_str, "downloading"); let args = [ "-fsSL", - "--connect-timeout", "15", - "--max-time", &max_secs.to_string(), - "-o", &path_str, + "--connect-timeout", + "15", + "--max-time", + &max_secs.to_string(), + "-o", + &path_str, url, ]; let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?; @@ -419,7 +482,10 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> { /// Download rust-analyzer from GitHub releases and install into /// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. -fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result { +fn install_rust_analyzer_binary( + env: &EnvInfo, + progress: ProgressFn<'_>, +) -> Result { let base = lsp_install_dir("rust-analyzer")?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?; @@ -434,9 +500,13 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu let gz = base.join("rust-analyzer.gz"); let target = base.join("rust-analyzer"); - if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); } + if let Some(cb) = progress { + cb("Rust: downloading prebuilt binary..."); + } 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()]) .map_err(|e| format!("gunzip spawn: {e}"))?; if !ok { @@ -452,7 +522,9 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("chmod: {e}"))?; } - if let Some(cb) = progress { cb("Rust: installed ✓"); } + if let Some(cb) = progress { + cb("Rust: installed ✓"); + } Ok(target) } @@ -464,14 +536,24 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result { let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz"; let tarball = base.join("jdtls.tar.gz"); - if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); } + if let Some(cb) = progress { + cb("Java: downloading JDT-LS (~150MB)..."); + } download_url(url, &tarball, 300)?; - if let Some(cb) = progress { cb("Java: extracting..."); } + if let Some(cb) = progress { + cb("Java: extracting..."); + } - let (ok, out) = run_command("tar", &[ - "-xzf", tarball.to_str().unwrap_or(""), - "-C", base.to_str().unwrap_or("."), - ]).map_err(|e| format!("tar spawn: {e}"))?; + let (ok, out) = run_command( + "tar", + &[ + "-xzf", + tarball.to_str().unwrap_or(""), + "-C", + base.to_str().unwrap_or("."), + ], + ) + .map_err(|e| format!("tar spawn: {e}"))?; if !ok { return Err(format!("tar: {}", out.trim())); } @@ -510,12 +592,18 @@ exec java \ std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) .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) } /// Dispatch a sentinel download tier to the correct helper. -fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Result { +fn run_download_tier( + name: &str, + env: &EnvInfo, + progress: ProgressFn<'_>, +) -> Result { match name { DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress), DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress), @@ -523,59 +611,17 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res } } -/// Render the "install by hand" message shown to the user when every -/// automated tier fails. -fn manual_instructions(def: &LanguageServerDef) -> String { - match def.language.as_str() { - "rust" => "Install rust-analyzer:\n \ - Arch: sudo pacman -S rust-analyzer\n \ - macOS: brew install rust-analyzer\n \ - Any: cargo install --locked rust-analyzer\n \ - Rustup: rustup component add rust-analyzer" - .to_string(), - "typescript" => "Install typescript-language-server:\n \ - npm install -g typescript typescript-language-server\n \ - Arch: sudo pacman -S typescript-language-server" - .to_string(), - "go" => "Install gopls:\n \ - go install golang.org/x/tools/gopls@latest\n \ - Arch: sudo pacman -S gopls" - .to_string(), - "java" => "Install Eclipse JDT-LS:\n \ - Arch: sudo pacman -S eclipse-jdt-ls\n \ - Debian: sudo apt install eclipse-jdt-ls\n \ - macOS: brew install jdtls\n \ - Other: see https://.eclipse.org/jdtls/#download" - .to_string(), - _ => format!("No automated install available for '{}'.", def.language), - } -} - -/// Try to provision a single language server. -/// -/// Flow: check whether any `binary_names` candidate is already on PATH -/// → if yes, return `AlreadyAvailable` → otherwise walk -/// `install_tiers` in order, skipping tiers whose `requires` -/// binaries are missing → for each viable tier, run the install -/// command (120s timeout) → if it succeeds AND the binary now -/// appears on PATH (or the tier is jdtls-manual returning a -/// launcher path), return Installed → if every tier fails, return -/// Failed with the last error and manual install instructions. -/// -/// Why we re-check `which` after the install: `rustup component add` -/// can exit 0 even if the binary wasn't actually placed on PATH (rare, -/// but happens with broken rustup installs). Re-checking gives us a -/// real signal rather than trusting the exit code alone. -#[allow(dead_code)] -pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult { - provision_single_with_progress(def, env, None) -} - -fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progress: ProgressFn<'_>) -> ProvisionResult { +fn provision_single_with_progress( + def: &LanguageServerDef, + env: &EnvInfo, + progress: ProgressFn<'_>, +) -> ProvisionResult { // 1. Check PATH. for bin in &def.binary_names { if let Some(path) = which(bin) { - if let Some(cb) = progress { cb(&format!("{}: already installed (PATH)", def.language)); } + if let Some(cb) = progress { + cb(&format!("{}: already installed (PATH)", def.language)); + } return ProvisionResult::AlreadyAvailable { server_name: def.name.clone(), language: def.language.clone(), @@ -586,7 +632,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre // 2. Check download-install directory (~/.local/share/zesdex/lsp//...). if let Some(path) = previous_download_install(def) { - if let Some(cb) = progress { cb(&format!("{}: found previous install", def.language)); } + if let Some(cb) = progress { + cb(&format!("{}: found previous install", def.language)); + } return ProvisionResult::AlreadyAvailable { server_name: def.name.clone(), language: def.language.clone(), @@ -594,30 +642,42 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre }; } - if let Some(cb) = progress { cb(&format!("{}: checking install options...", def.language)); } + if let Some(cb) = progress { + cb(&format!("{}: checking install options...", def.language)); + } let mut last_reason = String::from("no install tiers succeeded"); for tier in &def.install_tiers { // Prerequisite gating let prereqs_met = tier.requires.iter().all(|req| match req.as_str() { - "rustup" => env.has_rustup, "npm" => env.has_npm, - "go" => env.has_go, "java" => env.has_java, - "cargo" => env.has_cargo, "curl" => env.has_curl, - "tar" => env.has_tar, "pacman" => env.has_pacman, - "apt" => env.has_apt, "brew" => env.has_brew, - "dnf" => env.has_dnf, _ => which(req).is_some(), + "rustup" => env.rust.has_rustup, + "npm" => env.web.has_npm, + "go" => env.web.has_go, + "java" => env.web.has_java, + "cargo" => env.rust.has_cargo, + "curl" => env.platform.has_curl, + "tar" => env.platform.has_tar, + "pacman" => env.pacman_brew.has_pacman, + "apt" => env.apt_dnf.has_apt, + "brew" => env.pacman_brew.has_brew, + "dnf" => env.apt_dnf.has_dnf, + _ => which(req).is_some(), }); if !prereqs_met { let skip = format!("{}: {} — missing prerequisite", def.language, tier.label); - if let Some(cb) = progress { cb(&skip); } + if let Some(cb) = progress { + cb(&skip); + } last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label); warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites"); continue; } let trying = format!("{}: {}...", def.language, tier.label); - if let Some(cb) = progress { cb(&trying); } + if let Some(cb) = progress { + cb(&trying); + } // Download sentinel → helper. if tier.command.starts_with("__download_") && tier.command.ends_with("__") { @@ -647,7 +707,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre .iter() .find_map(|b| which(b).map(|p| p.to_string_lossy().to_string())); if let Some(path) = located { - if let Some(cb) = progress { cb(&format!("{}: installed ✓", def.language)); } + if let Some(cb) = progress { + cb(&format!("{}: installed ✓", def.language)); + } info!(server = %def.name, tier = %tier.label, binary = %path, "installed"); return ProvisionResult::Installed { server_name: def.name.clone(), @@ -671,58 +733,36 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre } } - let manual = manual_instructions(def); ProvisionResult::Failed { - language: def.language.clone(), server_name: def.name.clone(), - reason: last_reason, manual_instructions: manual, + language: def.language.clone(), + server_name: def.name.clone(), + reason: last_reason, } } -/// Provision every supported server in order, returning one -/// `ProvisionResult` per server. -/// -/// Flow: `detect_env()` once → for each server in `supported_servers()` -/// call `provision_single()` → collect results. Order matches -/// `supported_servers()` (rust, typescript, go, java). -#[allow(dead_code)] -pub fn provision_all() -> Vec { - let env = detect_env(); - info!( - linux = env.is_linux, - macos = env.is_macos, - rustup = env.has_rustup, - cargo = env.has_cargo, - npm = env.has_npm, - go = env.has_go, - java = env.has_java, - curl = env.has_curl, - tar = env.has_tar, - pacman = env.has_pacman, - apt = env.has_apt, - brew = env.has_brew, - dnf = env.has_dnf, - "starting LSP provisioning" - ); - supported_servers() - .iter() - .map(|def| provision_single(def, &env)) - .collect() -} - -/// Like `provision_all` but calls `progress` with a human-readable status +/// Provision every supported server with progress callbacks with a human-readable status /// string at each stage of each server's install attempt. pub fn provision_all_with_progress(progress: ProgressFn) -> Vec { let env = detect_env(); if let Some(cb) = progress { let flags = [ - ("rustup", env.has_rustup), ("cargo", env.has_cargo), - ("npm", env.has_npm), ("go", env.has_go), ("java", env.has_java), - ("curl", env.has_curl), ("tar", env.has_tar), - ("pacman", env.has_pacman), ("apt", env.has_apt), ("brew", env.has_brew), + ("rustup", env.rust.has_rustup), + ("cargo", env.rust.has_cargo), + ("npm", env.web.has_npm), + ("go", env.web.has_go), + ("java", env.web.has_java), + ("curl", env.platform.has_curl), + ("tar", env.platform.has_tar), + ("pacman", env.pacman_brew.has_pacman), + ("apt", env.apt_dnf.has_apt), + ("brew", env.pacman_brew.has_brew), ]; - let avail: String = flags.iter() - .filter(|(_, v)| *v).map(|(k, _)| *k) - .collect::>().join(", "); + let avail: String = flags + .iter() + .filter(|(_, v)| *v) + .map(|(k, _)| *k) + .collect::>() + .join(", "); cb(&format!("LSP: environment ready — {avail}")); } supported_servers() @@ -779,9 +819,13 @@ pub fn auto_connect(manager: &Arc>, results: &[ProvisionResult }; // Build extension slice for connect_with_extensions. - let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::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(&binary, &[], &language, &ext_refs) { Ok(()) => { info!( name = %name, diff --git a/src/app/mcp/manager.rs b/src/app/mcp/manager.rs index deae5e9..5e0ad0f 100644 --- a/src/app/mcp/manager.rs +++ b/src/app/mcp/manager.rs @@ -1,13 +1,11 @@ //! MCP server connection management: spawning/talking to stdio child //! processes and HTTP endpoints, and adapting their advertised tools to //! the crate's `Tool` trait. - -use serde_json::{json, Value}; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; use std::sync::{Arc, Mutex, OnceLock}; - const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000; const MCP_CALL_TIMEOUT_MS: u64 = 60_000; @@ -35,13 +33,8 @@ fn mcp_static_str(s: &str) -> &'static str { /// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum McpTransport { - Stdio { - command: String, - args: Vec, - }, - StreamableHttp { - url: String, - }, + Stdio { command: String, args: Vec }, + StreamableHttp { url: String }, } /// A single tool advertised by an MCP server, as returned by `tools/list`. @@ -104,8 +97,8 @@ impl StdioChild { self.stdin.flush()?; let mut response_line = String::new(); - let deadline = std::time::Instant::now() - + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS); + let deadline = + std::time::Instant::now() + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS); loop { if std::time::Instant::now() > deadline { anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms"); @@ -136,7 +129,9 @@ impl StdioChild { line_truncated = true; // Consume rest of line to keep stream in sync loop { - let buf = self.stdout.fill_buf() + let buf = self + .stdout + .fill_buf() .map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?; if buf.is_empty() { anyhow::bail!("MCP stdio child closed mid-line"); @@ -152,9 +147,7 @@ impl StdioChild { response_line.push(byte as char); } if line_truncated { - anyhow::bail!( - "MCP response line exceeded {MAX_LINE_LENGTH} byte limit", - ); + anyhow::bail!("MCP response line exceeded {MAX_LINE_LENGTH} byte limit"); } let trimmed = response_line.trim(); if trimmed.is_empty() { @@ -172,12 +165,16 @@ impl StdioChild { })); } } - } // close fn call -} // close impl StdioChild + } // close fn call +} // close impl StdioChild -pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result { +pub(crate) fn spawn_stdio_child( + command: &str, + extra_args: &[String], +) -> anyhow::Result { let parts: Vec<&str> = command.split_whitespace().collect(); - let (prog, prog_args) = parts.split_first() + let (prog, prog_args) = parts + .split_first() .ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?; let mut cmd = std::process::Command::new(prog); @@ -189,12 +186,17 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow: // rather than discarded silently, making connectivity issues debugable. 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}"))?; - let stdin = child.stdin.take() + let stdin = child + .stdin + .take() .ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?; - let stdout = child.stdout.take() + let stdout = child + .stdout + .take() .ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?; let mut mcp = StdioChild { @@ -203,17 +205,20 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow: next_id: 0, }; - let deadline = std::time::Instant::now() - + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS); + let deadline = + std::time::Instant::now() + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS); - let init_result = mcp.call("initialize", &json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "zesdex", - "version": "0.1.0" - } - })); + let init_result = mcp.call( + "initialize", + &json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "zesdex", + "version": "0.1.0" + } + }), + ); if std::time::Instant::now() > deadline { anyhow::bail!("MCP initialize timed out"); @@ -236,21 +241,29 @@ fn call_via_stdio( // Reuse the persistent child handle if available; otherwise spawn a new one. let mut guard; 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 } else { let mut fresh = spawn_stdio_child(command, extra_args)?; - let result = fresh.call("tools/call", &json!({ - "name": tool_name, - "arguments": tool_args - }))?; + let result = fresh.call( + "tools/call", + &json!({ + "name": tool_name, + "arguments": tool_args + }), + )?; return Ok(extract_text_content(&result)); }; - let result = child.call("tools/call", &json!({ - "name": tool_name, - "arguments": tool_args - }))?; + let result = child.call( + "tools/call", + &json!({ + "name": tool_name, + "arguments": tool_args + }), + )?; Ok(extract_text_content(&result)) } @@ -289,7 +302,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul } }); - let resp = client.post(url) + let resp = client + .post(url) .header("Content-Type", "application/json") .json(&body) .send() @@ -304,7 +318,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul 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}"))?; if let Some(err) = response.get("error") { @@ -321,13 +336,18 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul fn extract_text_content(result: &Value) -> String { if let Some(content) = result.get("content") { if let Some(arr) = content.as_array() { - let text: Vec = arr.iter().filter_map(|item| { - if item.get("type").and_then(|t| t.as_str()) == Some("text") { - item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string) - } else { - None - } - }).collect(); + let text: Vec = arr + .iter() + .filter_map(|item| { + if item.get("type").and_then(|t| t.as_str()) == Some("text") { + item.get("text") + .and_then(|t| t.as_str()) + .map(std::string::ToString::to_string) + } else { + None + } + }) + .collect(); if !text.is_empty() { return text.join("\n"); } @@ -372,12 +392,17 @@ impl crate::tool::Tool for McpToolAdapter { fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result { match &self.transport { - McpTransport::Stdio { command, args: extra_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 } => { - call_via_http(url, &self.tool_name, args) - } + McpTransport::Stdio { + command, + args: extra_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 } => call_via_http(url, &self.tool_name, args), } } } @@ -400,27 +425,35 @@ impl McpManager { /// /// Return: boxed `Tool` trait objects ready to merge into the harness's tool list. pub fn as_tools(&self) -> Vec> { - self.servers.iter().flat_map(|server| { - let handle = server.child_handle.clone(); - server.tools.iter().map(move |info| { - let adapter: Box = Box::new(McpToolAdapter { - tool_name: info.name.clone(), - server_name: server.name.clone(), - transport: server.transport.clone(), - description: info.description.clone(), - parameters: info.input_schema.clone(), - child_handle: handle.clone(), - }); - adapter + self.servers + .iter() + .flat_map(|server| { + let handle = server.child_handle.clone(); + server.tools.iter().map(move |info| { + let adapter: Box = Box::new(McpToolAdapter { + tool_name: info.name.clone(), + server_name: server.name.clone(), + transport: server.transport.clone(), + description: info.description.clone(), + parameters: info.input_schema.clone(), + child_handle: handle.clone(), + }); + adapter + }) }) - }).collect() + .collect() } /// Connects to an MCP server via stdio by spawning the child process, running /// the `initialize` handshake, calling `tools/list`, and registering the server /// with its advertised tools in `self.servers`. The child process stays alive /// for subsequent `tools/call` invocations via the stored `McpServer.tools`. - pub fn connect_stdio(&mut self, name: &str, command: &str, extra_args: &[String]) -> anyhow::Result<()> { + pub fn connect_stdio( + &mut self, + name: &str, + command: &str, + extra_args: &[String], + ) -> anyhow::Result<()> { let transport = McpTransport::Stdio { command: command.to_string(), args: extra_args.to_vec(), @@ -430,19 +463,32 @@ impl McpManager { let result = child.call("tools/list", &json!({}))?; let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) { - tool_list.iter().filter_map(|t| { - Some(McpToolInfo { - name: t.get("name")?.as_str()?.to_string(), - description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| { - tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?")); - "" - }).to_string(), - input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| { - tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?")); - serde_json::Value::Null - }), + tool_list + .iter() + .filter_map(|t| { + Some(McpToolInfo { + name: t.get("name")?.as_str()?.to_string(), + description: t + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + tracing::warn!( + "[mcp] tool {} missing description", + t.get("name").and_then(|n| n.as_str()).unwrap_or("?") + ); + "" + }) + .to_string(), + input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| { + tracing::warn!( + "[mcp] tool {} missing inputSchema", + t.get("name").and_then(|n| n.as_str()).unwrap_or("?") + ); + serde_json::Value::Null + }), + }) }) - }).collect() + .collect() } else { Vec::new() }; @@ -458,12 +504,4 @@ impl McpManager { Ok(()) } - - /// Removes a server by name. Returns `true` if a server was found and removed. - #[allow(dead_code)] - pub fn disconnect(&mut self, name: &str) -> bool { - let len = self.servers.len(); - self.servers.retain(|s| s.name != name); - self.servers.len() < len - } } diff --git a/src/app/mcp/mod.rs b/src/app/mcp/mod.rs index 781c1e6..92b255b 100644 --- a/src/app/mcp/mod.rs +++ b/src/app/mcp/mod.rs @@ -1,4 +1,3 @@ //! Model Context Protocol (MCP) client: connects to external MCP servers //! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait. - pub mod manager; diff --git a/src/app/mod.rs b/src/app/mod.rs index 74ea31f..bb857f9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,13 +1,13 @@ //! Top-level application module: harness, modes, runtime loop, state, //! workflows, subagents, review, background bash, MCP integration, and //! native LSP client. +pub mod bgbash; pub mod harness; +pub mod lsp; +pub mod mcp; pub mod mode; +pub mod review; pub mod runtime; pub mod state; -pub mod workflow; pub mod subagent; -pub mod review; -pub mod bgbash; -pub mod mcp; -pub mod lsp; +pub mod workflow; diff --git a/src/app/mode/bash.rs b/src/app/mode/bash.rs index 77eca8a..907355f 100644 --- a/src/app/mode/bash.rs +++ b/src/app/mode/bash.rs @@ -1,5 +1,4 @@ //! Bash mode: handles submitting a shell command from the bash input panel. - use crate::app::state::rest::AppStateRest; /// Launch a background bash job for the submitted command. diff --git a/src/app/mode/editor.rs b/src/app/mode/editor.rs index f42c057..36a3589 100644 --- a/src/app/mode/editor.rs +++ b/src/app/mode/editor.rs @@ -1,6 +1,5 @@ //! Editor mode: a minimal in-TUI line editor for viewing/modifying a file, //! with bounded undo history. - use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; @@ -66,7 +65,9 @@ impl EditorState { self.cursor_line += 1; } self.cursor_col = self.cursor_col.min( - self.content.get(self.cursor_line).map_or(0, std::string::String::len), + self.content + .get(self.cursor_line) + .map_or(0, std::string::String::len), ); } @@ -114,10 +115,9 @@ impl EditorState { /// the char directly → mark state dirty. pub fn handle_editor_input(state: &mut AppStateRest, text: &str) { let editor = &mut state.misc.editor; - if editor.is_none() { + let Some(ed) = editor.as_mut() else { return; - } - let ed = editor.as_mut().unwrap(); + }; for c in text.chars() { match c { '\n' | '\r' => { diff --git a/src/app/mode/effort.rs b/src/app/mode/effort.rs index be0ee84..196f202 100644 --- a/src/app/mode/effort.rs +++ b/src/app/mode/effort.rs @@ -1,7 +1,11 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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 //! LLM's temperature and `max_tokens` for subsequent turns. - use crate::app::state::rest::AppStateRest; pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; diff --git a/src/app/mode/help.rs b/src/app/mode/help.rs index 511fb41..2675139 100644 --- a/src/app/mode/help.rs +++ b/src/app/mode/help.rs @@ -1,5 +1,4 @@ //! Help mode: static help text and the action that opens/closes the help overlay. - use crate::app::runtime::actions::Action; use crate::app::state::types::Overlay; diff --git a/src/app/mode/key_input.rs b/src/app/mode/key_input.rs index 2b15813..a180b5d 100644 --- a/src/app/mode/key_input.rs +++ b/src/app/mode/key_input.rs @@ -1,5 +1,4 @@ //! Key input mode: raw text capture overlay used for one-off key/text prompts. - use crate::app::state::rest::AppStateRest; /// Replace the input buffer with the given text and mark state dirty. diff --git a/src/app/mode/learning.rs b/src/app/mode/learning.rs index 332467b..b0a09ca 100644 --- a/src/app/mode/learning.rs +++ b/src/app/mode/learning.rs @@ -33,14 +33,16 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec { let scope_str = match p.lesson.scope { crate::app::review::LessonScope::Project => "project", crate::app::review::LessonScope::Global => "global", - }.to_string(); + } + .to_string(); let conf_str = match p.lesson.confidence { crate::app::review::Confidence::Human => "human", crate::app::review::Confidence::Verified => "verified", crate::app::review::Confidence::Unverified => "unverified", crate::app::review::Confidence::Auto => "auto", - }.to_string(); + } + .to_string(); items.push(LearningItem::Pending { name: p.lesson.name, diff --git a/src/app/mode/loading.rs b/src/app/mode/loading.rs index 43baedd..e5eed9f 100644 --- a/src/app/mode/loading.rs +++ b/src/app/mode/loading.rs @@ -1,5 +1,4 @@ //! Loading mode: transient overlay shown while waiting on an async operation. - use crate::app::state::rest::AppStateRest; pub const LOADING_MESSAGES: &[&str] = &[ diff --git a/src/app/mode/mcp.rs b/src/app/mode/mcp.rs index 718fa1c..38b33d9 100644 --- a/src/app/mode/mcp.rs +++ b/src/app/mode/mcp.rs @@ -1,5 +1,4 @@ //! MCP mode: overlay for connecting to a configured MCP server. - use crate::app::state::rest::AppStateRest; /// Placeholder entry point for connecting to an MCP server by name. diff --git a/src/app/mode/mod.rs b/src/app/mode/mod.rs index d3c6d0e..9d7e3ec 100644 --- a/src/app/mode/mod.rs +++ b/src/app/mode/mod.rs @@ -1,14 +1,13 @@ //! TUI mode definitions and per-mode input/action handlers, one submodule //! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.). - pub mod bash; pub mod editor; pub mod effort; pub mod key_input; pub mod mcp; +pub mod learning; pub mod quit_confirm; pub mod rewind; pub mod settings; pub mod todo; -pub mod learning; diff --git a/src/app/mode/quit_confirm.rs b/src/app/mode/quit_confirm.rs index 125a81b..32505c3 100644 --- a/src/app/mode/quit_confirm.rs +++ b/src/app/mode/quit_confirm.rs @@ -1,5 +1,4 @@ //! Quit-confirm mode: the "are you sure?" overlay shown before exiting. - use crate::app::runtime::actions::Action; /// Translate the user's yes/no answer on the quit-confirm overlay into an action. diff --git a/src/app/mode/rewind.rs b/src/app/mode/rewind.rs index cbc7c1f..d9686da 100644 --- a/src/app/mode/rewind.rs +++ b/src/app/mode/rewind.rs @@ -1,13 +1,19 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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 //! session's `SQLite` blob store. - use crate::app::state::rest::AppStateRest; use sha2::Digest; /// Returns the number of stored pre-edit blobs (snapshots) for this session. pub fn rewind_count(state: &AppStateRest) -> usize { - let Ok(conn) = open_session_db(&state.session_dir) else { return 0 }; + let Ok(conn) = open_session_db(&state.session_dir) else { + return 0; + }; crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) .ok() .map_or(0, |keys| keys.len()) @@ -51,7 +57,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { } let blob_key = &keys[index]; - let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) { + let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) + { Ok(Some(b)) => b, Ok(None) => { state.push_toast(crate::app::state::types::Toast::new( @@ -74,8 +81,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { // Look up the path from the edit log — the blob key is the tool_call_id. // The edit log doesn't store the tool_call_id directly, so fall back to the // path from the most recent write/edit entry. - let restore_path = find_edit_path(state, blob_key) - .unwrap_or_else(|| state.session_dir.join("snapshot.dat")); + let restore_path = + find_edit_path(state, blob_key).unwrap_or_else(|| state.session_dir.join("snapshot.dat")); match std::fs::write(&restore_path, &bytes) { Ok(()) => { @@ -119,6 +126,10 @@ fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result Option { let el = crate::model::editlog::EditLog::new(&state.session_dir); - let entry = el.entries.iter().rev().find(|e| e.tool == "write" || e.tool == "edit")?; + let entry = el + .entries + .iter() + .rev() + .find(|e| e.tool == "write" || e.tool == "edit")?; Some(std::path::PathBuf::from(&entry.path)) } diff --git a/src/app/mode/settings.rs b/src/app/mode/settings.rs index 0e30605..8ca1552 100644 --- a/src/app/mode/settings.rs +++ b/src/app/mode/settings.rs @@ -3,8 +3,7 @@ //! Flow: exposes small mutation functions (currently just cycling the //! internet access mode) invoked by keybindings while the settings overlay //! is active. - -use crate::model::settings::{Settings, InternetMode}; +use crate::model::settings::{InternetMode, Settings}; /// Advance the internet access mode to the next value in the cycle. /// diff --git a/src/app/mode/todo.rs b/src/app/mode/todo.rs index 3d505b1..9dbd4ae 100644 --- a/src/app/mode/todo.rs +++ b/src/app/mode/todo.rs @@ -2,7 +2,6 @@ //! //! Flow: exposes the toggle handler invoked by a keybinding to show/hide //! the todo overlay. - use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs index 9baf458..dfb9e19 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -76,7 +76,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { return false; } let Some(runtime) = &state.session_runtime else { return false }; - if !state.settings.review_enabled { + if !state.settings.flags.review_enabled { return false; } if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 { diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 2054a37..c0b6e64 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -545,44 +545,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string())); } Action::Compact => { - let Some(messages) = state.session_runtime.as_ref().map(|rt| rt.messages.clone()) else { - return; - }; - if messages.is_empty() { - return; - } - let (api_key, model, base_url) = match resolve_llm_client_config(state) { - Ok(v) => v, - Err(msg) => { - state.push_toast(Toast::new(ToastKind::Error, msg)); - return; - } - }; - let max_wire_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); - let turn_events = state.turn_events.clone(); - - state.push_toast(Toast::new(ToastKind::Info, "Compacting conversation history...".to_string())); - - // Manual /compact previously ran synchronously and always - // passed `client: None` to shape_messages, so it never got - // LLM summarization — only automatic mid-turn compaction did. - // Running this on a background thread (same pattern as - // spawn_turn) fixes that asymmetry: both paths now summarize - // dropped history with the LLM instead of one silently - // falling back to a bare placeholder. - std::thread::spawn(move || { - let client = crate::service::provider::LlmClient::new(api_key, model, base_url); - let (deduped, _) = crate::app::runtime::context::dedup::collapse(&messages); - let token_count: usize = deduped.iter() - .map(crate::app::runtime::context::tokens::count_message_tokens) + let max_wire_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window) + .unwrap_or(state.app_config.default_context_window) as usize; + + if let Some(ref mut rt) = state.session_runtime { + let total_chars: usize = rt.messages.iter() + .filter_map(|m| m.content.as_deref()) + .map(str::len) .sum(); - let compacted = crate::app::runtime::context::shaping::shape_messages( - &deduped, token_count, max_wire_tokens, true, Some(&client), - ); - if let Ok(mut q) = turn_events.lock() { - q.push_back(TurnEvent::Compacted(compacted)); - } - }); + let token_estimate = total_chars / 3; + rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None); + state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string())); + state.dirty = true; + } } Action::LessonAccept { name } => { if let Some(ref rt) = state.session_runtime { @@ -623,45 +600,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { } } -/// Resolve the API key, model name, and base URL for the currently -/// configured provider. -/// -/// Flow: look up the provider's `ProviderConfig` for its `api_base` -> -/// resolve the API key from `Settings.api_keys`, falling back to the -/// provider's `api_key_env` environment variable, then its -/// `default_api_key`, then the crate-wide empty-string default. -/// -/// Why: this exact resolution was duplicated between `spawn_turn` and -/// needed again for `Action::Compact`'s background-thread LLM call — -/// factored out so both stay in sync. -/// -/// Return: `Ok((api_key, model, base_url))`, or `Err(message)` — a -/// user-facing string — if the configured provider has no entry in -/// `AppConfig.providers` at all. -fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option), String> { - let base_url = state.app_config.providers.get(&state.settings.provider).map(|p| p.api_base.clone()); - let Some(base_url) = base_url else { - return Err(format!( - "Provider '{}' is not configured — no matching entry found. \ - Pick a different provider in Settings, or configure it.", - state.settings.provider - )); - }; - let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); - if api_key.is_empty() { - if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { - api_key = provider_cfg.api_key_env.as_ref() - .and_then(|env| std::env::var(env).ok()) - .or_else(|| provider_cfg.default_api_key.clone()) - .unwrap_or_default(); - } - } - if api_key.is_empty() { - api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); - } - Ok((api_key, state.settings.model.clone(), Some(base_url))) -} - /// Spawn a background thread that runs one full LLM turn. /// /// Flow: check that no turn is currently in-flight → bail if so → @@ -692,17 +630,42 @@ fn spawn_turn(state: &AppStateRest) { if messages.is_empty() { return; } - let (api_key, model, base_url) = match resolve_llm_client_config(state) { - Ok(v) => v, - Err(msg) => { - if let Ok(mut q) = state.turn_events.lock() { - q.push_back(TurnEvent::Error(msg)); - } - return; + let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); + let model = state.settings.model.clone(); + let base_url = state.app_config.providers.get(&state.settings.provider) + .map(|p| p.api_base.clone()); + let context_window = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window) + .unwrap_or(state.app_config.default_context_window) as usize; + // The selected provider has no entry in app_config at all (e.g. the + // Claude-settings auto-detection that registers "claude" found nothing + // this run). Without this check, LlmClient::new silently falls back to + // the zen default base URL while keeping this provider's model name — + // a mismatched request that reaches a real server and comes back as a + // confusing "Missing API key" 401 from an unrelated provider, instead + // of the actual problem: the configured provider doesn't exist. + if base_url.is_none() { + if let Ok(mut q) = state.turn_events.lock() { + q.push_back(TurnEvent::Error(format!( + "Provider '{}' is not configured — no matching entry found. \ + Pick a different provider in Settings, or configure it.", + state.settings.provider + ))); } - }; - let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); - let concise_output = state.settings.concise_output; + return; + } + if api_key.is_empty() { + if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { + api_key = provider_cfg.api_key_env.as_ref() + .and_then(|env| std::env::var(env).ok()) + .or_else(|| provider_cfg.default_api_key.clone()) + .unwrap_or_default(); + } + } + if api_key.is_empty() { + api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); + } let (temperature, max_tokens) = crate::app::mode::effort::generation_params( state.misc.effort_level, state.settings.max_tokens, @@ -747,7 +710,6 @@ fn spawn_turn(state: &AppStateRest) { max_tokens, abort_flag, hive_mind_converged, - concise_output, }; let result = run_agent_turn(&tc, &messages, &events_q); if let Err(e) = result { @@ -780,10 +742,6 @@ struct TurnCtx { /// of this turn — whether a hive-mind convergence already completed /// earlier in this session. hive_mind_converged: bool, - /// Snapshot of `Settings.concise_output` taken at the start of this - /// turn, so the system-prompt assembly above can read it without - /// `TurnCtx` needing a `Settings` reference. - concise_output: bool, } /// Build an ASCII tree of the workspace directory structure for the @@ -933,9 +891,8 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence /// handle tool calls, and loop until the LLM produces a non-tool response /// or runs out of unfinished todo items. /// -/// Flow: build system prompt with workspace tree → deduplicate messages -/// via `context::dedup::collapse` → optionally shape (compact) messages via -/// `context::shaping::{should_shape, shape_messages}` → call `chat_with_tools_streaming` +/// Flow: build system prompt with workspace tree → optionally shape +/// (compact) messages via `shortsend` → call `chat_with_tools_streaming` /// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`, /// and `Usage` events → on streaming success, handle tool calls (gated /// through `Harness::gate_tool_call`) or unwrap the final assistant @@ -970,23 +927,12 @@ fn run_agent_turn( // workspace tree and reads all memory files each time). let tree_info = generate_workspace_tree(&tc.workspace_roots); let memory_section = build_memory_section(&tc.ctx.memory_dir); - let concise_section = if tc.concise_output { - "\n\nWrite tersely: drop articles (a/an/the), filler words (just/really/basically/\ - actually/simply), pleasantries (sure/certainly/of course/happy to), and hedging. \ - Fragments are fine. Code, commands, file paths, and error text must stay byte-exact \ - — never abbreviate or paraphrase those. Exception: for destructive-operation \ - confirmations and security-relevant warnings, always give full detail regardless of \ - this instruction — clarity matters more than brevity when something risky is at stake." - } else { - "" - }; let system_text = format!( - "{}\n\n{}\n\n{}{}{}", + "{}\n\n{}\n\n{}{}", crate::resources::SYSTEM_PROMPT, crate::resources::SYSTEM_TOOLS, tree_info, memory_section, - concise_section, ); if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) { let sys = ChatMessage::system(system_text); @@ -1198,43 +1144,33 @@ fn run_agent_turn( let mut todo_retry_count = 0usize; loop { - // Dedup runs every iteration, unconditionally — repeated - // read-only tool calls (same tool + same arguments) are - // collapsed to their latest result before anything else, so - // context stays minimal from turn 1 instead of only shrinking - // once shaping's budget threshold trips. - let (deduped, dedup_changed) = crate::app::runtime::context::dedup::collapse(&msgs); - let token_count: usize = deduped.iter() - .map(crate::app::runtime::context::tokens::count_message_tokens) + let total_chars: usize = msgs.iter() + .filter_map(|m| m.content.as_deref()) + .map(str::len) .sum(); + let token_estimate = total_chars / 4; let max_wire_tokens = tc.context_window; - // Skip shaping if abort was requested — the non-streaming LLM - // call for summarization would block without checking abort_flag. + // Skip message compaction if abort was requested — the non-streaming + // LLM call for summarization would block without checking abort_flag. let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) - && crate::app::runtime::context::shaping::should_shape(token_count, max_wire_tokens, prev_shaped) + && crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) { prev_shaped = true; - let compacted = crate::app::runtime::context::shaping::shape_messages(&deduped, token_count, max_wire_tokens, false, Some(&tc.client)); - - // Dispatch to the main thread so the local session history is - // permanently updated and doesn't re-trigger shaping immediately - // on the next turn. + let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client)); + + // Dispatch the compacted messages to the main thread so the local session history + // is permanently compacted and doesn't trigger shaping again immediately on next turn. if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Compacted(compacted.clone())); } - + + // Also update our local `msgs` variable so the rest of the loop operates on the compacted version msgs.clone_from(&compacted); compacted } else { prev_shaped = false; - if dedup_changed { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Compacted(deduped.clone())); - } - msgs.clone_from(&deduped); - } - deduped + msgs.clone() }; let mut stream_started = false; @@ -1481,8 +1417,7 @@ fn run_agent_turn( } } - let squashed_output = crate::app::runtime::context::squash::apply(&tool_name, &output); - let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), squashed_output); + let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg); msgs.push(tool_msg); } @@ -1677,7 +1612,7 @@ fn execute_one_tool( /// `should_trigger_review` on `Tick`), only informs the user that /// a review has material to examine. fn maybe_trigger_review(state: &mut AppStateRest) { - if !state.settings.review_enabled { + if !state.settings.flags.review_enabled { return; } let edit_count = state diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index ecb036f..28f7d59 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -1,8 +1,8 @@ //! Maps parsed `/` slash commands into one or more `Action` variants //! that `apply_action` can process. -use crate::controller::command::Command; use crate::app::runtime::actions::Action; use crate::app::state::types::Overlay; +use crate::controller::command::Command; /// Convert a parsed `Command` into the corresponding sequence of `Action`s. /// diff --git a/src/app/runtime/context/dedup.rs b/src/app/runtime/context/dedup.rs index f9ef4c8..d355946 100644 --- a/src/app/runtime/context/dedup.rs +++ b/src/app/runtime/context/dedup.rs @@ -15,11 +15,10 @@ //! `git_operator`, ...) are never touched, even with identical //! arguments, because call order and repetition can be semantically //! meaningful (e.g. retrying a flaky `bash` command until it passes). - -use std::collections::HashMap; -use sha2::Digest; use crate::app::subagent::division::tool_scope::READ_TOOLS; use crate::dto::chat::message::{ChatMessage, Role}; +use sha2::Digest; +use std::collections::HashMap; const DUPLICATE_PLACEHOLDER: &str = "[duplicate result — superseded by a later identical call, see below]"; @@ -50,7 +49,9 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) { continue; } let Some(id) = &m.tool_call_id else { continue }; - let Some((name, args)) = call_info.get(id) else { continue }; + let Some((name, args)) = call_info.get(id) else { + continue; + }; if !READ_TOOLS.contains(&name.as_str()) { continue; } @@ -58,22 +59,30 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) { } let mut changed = false; - let result = messages.iter().enumerate().map(|(idx, m)| { - if m.role != Role::Tool { - return m.clone(); - } - let Some(id) = &m.tool_call_id else { return m.clone() }; - let Some((name, args)) = call_info.get(id) else { return m.clone() }; - if !READ_TOOLS.contains(&name.as_str()) { - return m.clone(); - } - let key = dedup_key(name, args); - if last_index_for_key.get(&key) == Some(&idx) { - return m.clone(); - } - changed = true; - ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string()) - }).collect(); + let result = messages + .iter() + .enumerate() + .map(|(idx, m)| { + if m.role != Role::Tool { + return m.clone(); + } + let Some(id) = &m.tool_call_id else { + return m.clone(); + }; + let Some((name, args)) = call_info.get(id) else { + return m.clone(); + }; + if !READ_TOOLS.contains(&name.as_str()) { + return m.clone(); + } + let key = dedup_key(name, args); + if last_index_for_key.get(&key) == Some(&idx) { + return m.clone(); + } + changed = true; + ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string()) + }) + .collect(); (result, changed) } @@ -102,7 +111,10 @@ mod tests { m.tool_calls = Some(vec![ToolCall { id: id.to_string(), type_: "function".to_string(), - function: ToolFunction { name: name.to_string(), arguments: args }, + function: ToolFunction { + name: name.to_string(), + arguments: args, + }, }]); m } @@ -172,9 +184,10 @@ mod tests { #[test] fn tool_result_with_no_matching_call_is_left_untouched() { - let messages = vec![ - ChatMessage::tool_result("orphan-id".to_string(), "some result".to_string()), - ]; + let messages = vec![ChatMessage::tool_result( + "orphan-id".to_string(), + "some result".to_string(), + )]; let (result, changed) = collapse(&messages); diff --git a/src/app/runtime/context/mod.rs b/src/app/runtime/context/mod.rs index 99afb61..e548da0 100644 --- a/src/app/runtime/context/mod.rs +++ b/src/app/runtime/context/mod.rs @@ -9,7 +9,6 @@ //! layer would only serve one of the two callers generically — the //! auto-loop already needs per-stage control to decide when to emit //! `TurnEvent::Compacted`. - pub mod dedup; pub mod shaping; pub mod squash; diff --git a/src/app/runtime/context/shaping.rs b/src/app/runtime/context/shaping.rs index 451df31..a6bbff4 100644 --- a/src/app/runtime/context/shaping.rs +++ b/src/app/runtime/context/shaping.rs @@ -3,7 +3,6 @@ //! the LLM API. Ported from the former `runtime::shortsend` — behavior //! is unchanged, only its token-counting now goes through //! `context::tokens` instead of an inline heuristic. - use super::tokens::count_tokens; use crate::dto::chat::message::ChatMessage; @@ -101,7 +100,8 @@ pub fn shape_messages( match llm.chat_with_tools_non_streaming(&req_msgs, None) { Ok(resp) => { if let Some(content) = resp.0.content { - summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]"); + summary_text = + format!("[Summary of compacted prior conversation:\n{content}\n]"); } } Err(e) => { @@ -136,7 +136,10 @@ mod tests { #[test] fn should_shape_uses_95_percent_threshold_once_already_shaped() { - assert!(!should_shape(900, 1000, true), "below 95% and already shaped: no re-trigger yet"); + assert!( + !should_shape(900, 1000, true), + "below 95% and already shaped: no re-trigger yet" + ); assert!(should_shape(950, 1000, true)); } @@ -186,9 +189,9 @@ mod tests { messages.push(ChatMessage::user(padded_message(i))); } let result = shape_messages(&messages, 100_000, 1000, true, None); - let has_placeholder = result.iter().any(|m| { - m.content.as_deref() == Some("[prior conversation compacted]") - }); + let has_placeholder = result + .iter() + .any(|m| m.content.as_deref() == Some("[prior conversation compacted]")); assert!(has_placeholder); } @@ -200,6 +203,9 @@ mod tests { } let result = shape_messages(&messages, 100_000, 1000, true, None); let last_content = messages.last().unwrap().content.clone(); - assert!(result.iter().any(|m| m.content == last_content), "most recent message must survive shaping"); + assert!( + result.iter().any(|m| m.content == last_content), + "most recent message must survive shaping" + ); } } diff --git a/src/app/runtime/context/squash.rs b/src/app/runtime/context/squash.rs index 62e965b..aa44c4e 100644 --- a/src/app/runtime/context/squash.rs +++ b/src/app/runtime/context/squash.rs @@ -10,7 +10,6 @@ //! conversation's token budget even on its first occurrence, long //! before `dedup`/`shaping` ever get a chance to act on repeats or //! overall budget. - use std::collections::HashSet; use std::fmt::Write; @@ -220,12 +219,24 @@ fn squash_log(text: &str) -> String { level_score + stack_boost }; - let mut error_idxs: Vec = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Error).collect(); - error_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal)); + let mut error_idxs: Vec = (0..lines.len()) + .filter(|&i| levels[i] == LogLevel::Error) + .collect(); + error_idxs.sort_by(|&a, &b| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); error_idxs.truncate(20); - let mut warn_idxs: Vec = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Warn).collect(); - warn_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal)); + let mut warn_idxs: Vec = (0..lines.len()) + .filter(|&i| levels[i] == LogLevel::Warn) + .collect(); + warn_idxs.sort_by(|&a, &b| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); warn_idxs.truncate(10); let mut keep: HashSet = HashSet::new(); @@ -257,7 +268,10 @@ fn squash_generic(text: &str, budget: usize) -> String { let mut keep: HashSet = (0..head_end).chain(tail_start..lines.len()).collect(); let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::() - + lines[tail_start..].iter().map(|l| l.len() + 1).sum::(); + + lines[tail_start..] + .iter() + .map(|l| l.len() + 1) + .sum::(); let mut prev = ""; for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) { let non_trivial = !line.trim().is_empty() && line != prev; @@ -324,8 +338,8 @@ mod tests { assert!(text.len() > SQUASH_FLOOR_BYTES); let result = apply("some_mcp_tool", &text); - let parsed: serde_json::Value = serde_json::from_str(&result) - .expect("squashed JSON must still be valid JSON"); + let parsed: serde_json::Value = + serde_json::from_str(&result).expect("squashed JSON must still be valid JSON"); assert_eq!(parsed["id"], "abc123", "short values must survive"); assert_eq!(parsed["note"], "hi", "short values must survive"); @@ -357,7 +371,11 @@ mod tests { let items = parsed["items"].as_array().unwrap(); assert_eq!(items[0].as_str().unwrap(), identifier, "index 0 is under the array cutoff and identifier-shaped, so it's kept under the normal rule"); - assert_eq!(items[2].as_str().unwrap(), identifier, "index 2 is still under the cutoff (past-third means index >= 3)"); + assert_eq!( + items[2].as_str().unwrap(), + identifier, + "index 2 is still under the cutoff (past-third means index >= 3)" + ); assert_ne!(items[3].as_str().unwrap(), identifier, "index 3 must be force-elided even though it's identifier-shaped and would survive at any earlier index"); } @@ -412,20 +430,34 @@ mod tests { let result = apply("grep", &text); - assert!(result.contains("src/file0.rs:0: error handling for case 0"), "generic keeps head"); - assert!(result.contains("src/file49.rs:49: error handling for case 49"), "generic keeps tail — squash_log would have dropped this"); + assert!( + result.contains("src/file0.rs:0: error handling for case 0"), + "generic keeps head" + ); + assert!( + result.contains("src/file49.rs:49: error handling for case 49"), + "generic keeps tail — squash_log would have dropped this" + ); } #[test] fn generic_large_text_is_truncated_with_omission_marker() { - let lines: Vec = (0..500).map(|i| format!("line number {i} of plain output")).collect(); + let lines: Vec = (0..500) + .map(|i| format!("line number {i} of plain output")) + .collect(); let text = lines.join("\n"); assert!(text.len() > SQUASH_FLOOR_BYTES); let result = apply("bash", &text); - assert!(result.contains("line number 0 of plain output"), "keeps head"); - assert!(result.contains("line number 499 of plain output"), "keeps tail"); + assert!( + result.contains("line number 0 of plain output"), + "keeps head" + ); + assert!( + result.contains("line number 499 of plain output"), + "keeps tail" + ); assert!(result.contains("lines omitted")); assert!(result.len() < text.len()); } diff --git a/src/app/runtime/context/tokens.rs b/src/app/runtime/context/tokens.rs index 7069c00..51e9efa 100644 --- a/src/app/runtime/context/tokens.rs +++ b/src/app/runtime/context/tokens.rs @@ -10,7 +10,6 @@ //! `o200k_base` is an approximation for non-OpenAI providers but is far //! closer than a flat byte-per-token guess; it's only used for the //! 85%/95% budget thresholds, not for billing-accurate counts. - use crate::dto::chat::message::ChatMessage; /// Count tokens in a single string under `o200k_base`. @@ -21,7 +20,9 @@ use crate::dto::chat::message::ChatMessage; /// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted /// as ordinary text, not interpreted as a control token. pub fn count_tokens(text: &str) -> usize { - tiktoken_rs::o200k_base_singleton().encode_ordinary(text).len() + tiktoken_rs::o200k_base_singleton() + .encode_ordinary(text) + .len() } /// Count tokens in a `ChatMessage`'s text content. diff --git a/src/app/runtime/context/window.rs b/src/app/runtime/context/window.rs index bf748cf..49761d3 100644 --- a/src/app/runtime/context/window.rs +++ b/src/app/runtime/context/window.rs @@ -5,7 +5,6 @@ //! had their own inline version — the status bar's copy additionally //! displayed "?" on no match instead of falling back like the other two, //! an inconsistency this unifies away). - use crate::model::app_config::AppConfig; use crate::model::settings::Settings; @@ -18,7 +17,9 @@ use crate::model::settings::Settings; /// /// Return: always a concrete token count, never "unknown". pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize { - app_config.model_roles.values() + app_config + .model_roles + .values() .find(|role| role.provider == settings.provider && role.model == settings.model) .and_then(|role| role.context_window) .unwrap_or(app_config.default_context_window) as usize @@ -32,13 +33,16 @@ mod tests { #[test] fn resolves_context_window_from_matching_model_role() { let mut app_config = AppConfig::default(); - app_config.model_roles.insert("default".to_string(), ModelRole { - provider: "zen".to_string(), - model: "deepseek-v4-flash-free".to_string(), - max_tokens: None, - context_window: Some(128_000), - temperature: None, - }); + app_config.model_roles.insert( + "default".to_string(), + ModelRole { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + max_tokens: None, + context_window: Some(128_000), + temperature: None, + }, + ); let mut settings = Settings::default(); settings.provider = "zen".to_string(); settings.model = "deepseek-v4-flash-free".to_string(); @@ -53,23 +57,32 @@ mod tests { settings.provider = "nonexistent".to_string(); settings.model = "nonexistent-model".to_string(); - assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize); + assert_eq!( + resolve(&app_config, &settings), + app_config.default_context_window as usize + ); } #[test] fn falls_back_to_default_when_matching_role_has_no_context_window_set() { let mut app_config = AppConfig::default(); - app_config.model_roles.insert("default".to_string(), ModelRole { - provider: "zen".to_string(), - model: "deepseek-v4-flash-free".to_string(), - max_tokens: None, - context_window: None, - temperature: None, - }); + app_config.model_roles.insert( + "default".to_string(), + ModelRole { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + max_tokens: None, + context_window: None, + temperature: None, + }, + ); let mut settings = Settings::default(); settings.provider = "zen".to_string(); settings.model = "deepseek-v4-flash-free".to_string(); - assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize); + assert_eq!( + resolve(&app_config, &settings), + app_config.default_context_window as usize + ); } } diff --git a/src/app/runtime/stream/mod.rs b/src/app/runtime/stream/mod.rs index c004b11..aaf9f78 100644 --- a/src/app/runtime/stream/mod.rs +++ b/src/app/runtime/stream/mod.rs @@ -1,8 +1,6 @@ -#![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 //! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done). pub mod turn; -pub mod tools; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -89,7 +87,7 @@ impl SseParser { /// provider-specific parsing layer. /// /// Return: 0, 1, or more `StreamEvent`s from the flushed frame. - #[allow(clippy::too_many_lines)] + fn flush_event(&mut self) -> Vec { let data = self.data_lines.join("\n"); self.data_lines.clear(); @@ -112,20 +110,32 @@ impl SseParser { if let Some(usage) = value.get("usage") { if !usage.is_null() { - 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"); - 0 - }); - 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"); - 0 - }); - let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64) + 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"); + 0 + }); + 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"); + 0 + }); + let total_tokens = usage + .get("total_tokens") + .and_then(serde_json::Value::as_u64) .unwrap_or_else(|| { tracing::warn!("[stream] total_tokens missing in usage chunk"); prompt_tokens + completion_tokens }); - events.push(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }); + events.push(StreamEvent::Usage { + prompt_tokens, + completion_tokens, + total_tokens, + }); } } @@ -143,23 +153,32 @@ impl SseParser { } // Reasoning token - if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) { + if let Some(reasoning) = + d.get("reasoning_content").and_then(|r| r.as_str()) + { d_events.push(StreamEvent::Reasoning(reasoning.to_string())); } // Tool calls — iterate ALL entries, not just first() - 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()) + { for tc in tool_calls { let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| { tracing::warn!("[stream] tool call delta missing index, defaulting to 0"); 0 }) as usize; - let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string); - let name = tc.get("function") + let id = tc + .get("id") + .and_then(|i| i.as_str()) + .map(std::string::ToString::to_string); + let name = tc + .get("function") .and_then(|f| f.get("name")) .and_then(|n| n.as_str()) .map(std::string::ToString::to_string); - let args_delta = tc.get("function") + let args_delta = tc + .get("function") .and_then(|f| f.get("arguments")) .and_then(|a| a.as_str()) .unwrap_or("") @@ -174,7 +193,9 @@ impl SseParser { } // Finish reason - if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) { + if let Some(reason) = + choice.get("finish_reason").and_then(|r| r.as_str()) + { if reason == "stop" || reason == "tool_calls" { d_events.push(StreamEvent::Done); } @@ -193,72 +214,6 @@ impl SseParser { events.append(&mut other_events); events } - - /// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that - /// reuse a parser instance across requests rather than constructing a fresh one. - #[allow(dead_code)] - pub fn reset(&mut self) { - self.buffer.clear(); - self.event_type = None; - self.data_lines.clear(); - } -} - -/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed -/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles -/// standard SSE framing directly), kept for providers/tests that feed raw chunks. -/// -/// Flow: parse `data` as JSON → extract first `choices[0].delta` → -/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based -/// on the fields present. -/// -/// Return: `Some(StreamEvent)` if the chunk contained recognisable -/// content, `None` otherwise. -#[allow(dead_code)] -pub fn parse_stream_chunk(data: &str) -> Option { - let value: Value = serde_json::from_str(data).ok()?; - if value == Value::Null { - return None; - } - let choices = value.get("choices")?.as_array()?; - let choice = choices.first()?; - let delta = choice.get("delta")?; - if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { - return Some(StreamEvent::Token(content.to_string())); - } - if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) { - return Some(StreamEvent::Reasoning(reasoning.to_string())); - } - if let Some(finish) = choice.get("finish_reason").and_then(|r| r.as_str()) { - if finish == "stop" || finish == "tool_calls" { - return Some(StreamEvent::Done); - } - } - if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { - if let Some(tc) = tool_calls.first() { - 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"); - 0 - }) as usize; - let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string); - let name = tc.get("function") - .and_then(|f| f.get("name")) - .and_then(|n| n.as_str()) - .map(std::string::ToString::to_string); - let args = tc.get("function") - .and_then(|f| f.get("arguments")) - .and_then(|a| a.as_str()) - .unwrap_or("") - .to_string(); - return Some(StreamEvent::ToolCallDelta { - index, - id, - name, - arguments_delta: args, - }); - } - } - None } #[cfg(test)] @@ -280,7 +235,10 @@ mod tests { fn feed_handles_chunk_split_mid_line() { let mut p = SseParser::new(); let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial"); - assert!(e1.is_empty(), "no event until the line and blank separator complete"); + assert!( + e1.is_empty(), + "no event until the line and blank separator complete" + ); let e2 = p.feed("\"}}]}\n\n"); assert_eq!(e2.len(), 1); match &e2[0] { @@ -300,9 +258,7 @@ mod tests { #[test] fn feed_emits_done_on_finish_reason_stop() { let mut p = SseParser::new(); - let events = p.feed( - "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", - ); + let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"); assert_eq!(events.len(), 1); assert!(matches!(events[0], StreamEvent::Done)); } @@ -315,7 +271,12 @@ mod tests { ); assert_eq!(events.len(), 1); match &events[0] { - StreamEvent::ToolCallDelta { index, id, name, arguments_delta } => { + StreamEvent::ToolCallDelta { + index, + id, + name, + arguments_delta, + } => { assert_eq!(*index, 0); assert_eq!(id.as_deref(), Some("call_1")); assert_eq!(name.as_deref(), Some("bash")); @@ -333,7 +294,11 @@ mod tests { ); assert_eq!(events.len(), 1); match &events[0] { - StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens } => { + StreamEvent::Usage { + prompt_tokens, + completion_tokens, + total_tokens, + } => { assert_eq!(*prompt_tokens, 10); assert_eq!(*completion_tokens, 5); assert_eq!(*total_tokens, 15); @@ -351,7 +316,11 @@ mod tests { assert_eq!(events.len(), 2); match (&events[0], &events[1]) { ( - StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }, + StreamEvent::Usage { + prompt_tokens, + completion_tokens, + total_tokens, + }, StreamEvent::Token(t), ) => { assert_eq!(*prompt_tokens, 10); diff --git a/src/app/runtime/stream/tools/mod.rs b/src/app/runtime/stream/tools/mod.rs deleted file mode 100644 index 8b04d1f..0000000 --- a/src/app/runtime/stream/tools/mod.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Standalone accumulator for streamed tool-call deltas. -//! -//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id, -//! name, arguments_delta)` chunks as they arrive over SSE → grows its -//! internal `Vec` as needed → `is_complete` reports once -//! every accumulated call has both a name and arguments. -//! -//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event` -//! but as an independent, reusable type for callers that want to track -//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight -//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future -//! use case. - -use super::turn::ParsedToolCall; -use serde_json::{json, Value}; - -/// Standalone tool-call delta accumulator, functionally equivalent to the accumulation -/// logic built into `StreamedTurn::apply_event`. Reserved for callers that want to track -/// tool-call deltas independently of a full `StreamedTurn` (e.g. a lighter-weight preview). -#[allow(dead_code)] -pub struct ToolCallAccumulator { - calls: Vec, -} - -#[allow(dead_code)] -impl ToolCallAccumulator { - /// Construct an empty accumulator with no tool calls tracked yet. - /// - /// Return: a fresh `ToolCallAccumulator`. - pub fn new() -> Self { - ToolCallAccumulator { calls: Vec::new() } - } - - /// Append a delta to the tool call at the given index, growing the - /// calls vector if needed. - pub fn add_delta( - &mut self, - index: usize, - id: Option<&str>, - name: Option<&str>, - arguments_delta: &str, - ) { - while self.calls.len() <= index { - self.calls.push(ParsedToolCall { - id: String::new(), - name: String::new(), - arguments: String::new(), - is_complete: false, - }); - } - let tc = &mut self.calls[index]; - if let Some(new_id) = id { - if !new_id.is_empty() { - tc.id = new_id.to_string(); - } - } - if let Some(new_name) = name { - if !new_name.is_empty() { - tc.name = new_name.to_string(); - } - } - tc.arguments.push_str(arguments_delta); - } - - /// Borrow the accumulated tool calls. - pub fn calls(&self) -> &[ParsedToolCall] { - &self.calls - } - - /// Return true once all tool calls have both a name and arguments. - pub fn is_complete(&self) -> bool { - !self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty()) - } - - /// Clear all accumulated calls (starting a fresh turn). - pub fn reset(&mut self) { - self.calls.clear(); - } - - /// Build a JSON-serialisable `Vec` of pending (non-empty-name) - /// tool calls, suitable for downstream inspection or replay. - pub fn pending_args(&self) -> Vec { - self.calls - .iter() - .filter(|tc| !tc.name.is_empty()) - .map(|tc| { - json!({ - "tool_call_id": tc.id, - "name": tc.name, - "arguments": tc.arguments, - }) - }) - .collect() - } -} - -impl Default for ToolCallAccumulator { - fn default() -> Self { - Self::new() - } -} diff --git a/src/app/runtime/stream/turn.rs b/src/app/runtime/stream/turn.rs index 5275feb..e606a4c 100644 --- a/src/app/runtime/stream/turn.rs +++ b/src/app/runtime/stream/turn.rs @@ -101,17 +101,7 @@ pub struct ParsedToolCall { pub is_complete: bool, } -impl ParsedToolCall { - /// Attempt to parse the accumulated argument string as JSON before - /// the tool call is marked complete — useful for a speculative preview. - /// - /// Return: `Some(Value)` if the arguments are parsable JSON, `None` - /// if still partial. - #[allow(dead_code)] - pub fn try_parse(&self) -> Option { - serde_json::from_str(&self.arguments).ok() - } -} +impl ParsedToolCall {} impl StreamedTurn { /// Create an empty turn accumulator. @@ -186,12 +176,12 @@ impl StreamedTurn { let mut msg = if self.tool_calls.is_empty() { ChatMessage::assistant(None) } else { - let tool_dtos: Vec = self.tool_calls + let tool_dtos: Vec = self + .tool_calls .iter() .filter(|tc| !tc.name.is_empty()) .map(|tc| { - let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) - { + let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) { Ok(v) => v, Err(e) => { let repaired = repair_incomplete_json(&tc.arguments); @@ -200,7 +190,8 @@ impl StreamedTurn { tracing::warn!( "[stream] tool call '{}' had truncated JSON \ arguments — repaired successfully: {}", - tc.name, e, + tc.name, + e, ); v } @@ -209,7 +200,9 @@ impl StreamedTurn { "[stream] tool call '{}' has invalid JSON \ arguments: {} (after repair: {}) — falling \ back to raw string", - tc.name, e, e2, + tc.name, + e, + e2, ); serde_json::Value::String(tc.arguments.clone()) } @@ -235,7 +228,10 @@ impl StreamedTurn { let full_content = if self.accumulated_reasoning.is_empty() { self.accumulated_content.clone() } else { - format!("\n{}\n\n\n{}", self.accumulated_reasoning, self.accumulated_content) + format!( + "\n{}\n\n\n{}", + self.accumulated_reasoning, self.accumulated_content + ) }; let content = if full_content.is_empty() { None @@ -259,7 +255,8 @@ impl StreamedTurn { /// Return: `Some((name, parse_error))` for the first bad tool call, or /// `None` if every tool call's arguments are complete, parsable JSON. pub fn incomplete_tool_call(&self) -> Option<(&str, String)> { - self.tool_calls.iter() + self.tool_calls + .iter() .filter(|tc| !tc.name.is_empty()) .find_map(|tc| { serde_json::from_str::(&tc.arguments) @@ -267,19 +264,6 @@ impl StreamedTurn { .map(|e| (tc.name.as_str(), e.to_string())) }) } - - /// Reserved accessor for callers that want to branch mid-stream before the turn - /// completes; the current wiring only inspects the final `build_assistant_message()`. - #[allow(dead_code)] - pub fn has_tool_calls(&self) -> bool { - self.tool_calls.iter().any(|tc| !tc.name.is_empty()) - } - - /// Reserved accessor mirroring `has_tool_calls` for mid-stream content peeks. - #[allow(dead_code)] - pub fn content(&self) -> &str { - &self.accumulated_content - } } impl Default for StreamedTurn { @@ -353,7 +337,10 @@ mod tests { let tcs = msg.tool_calls.expect("should produce tool calls"); assert_eq!(tcs.len(), 1); let args = &tcs[0].function.arguments; - assert!(args.is_object(), "args should be an object after repair: {args:?}"); + assert!( + args.is_object(), + "args should be an object after repair: {args:?}" + ); assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt")); assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short")); } @@ -361,7 +348,10 @@ mod tests { #[test] fn incomplete_tool_call_flags_truncated_json() { let mut turn = StreamedTurn::new(); - turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"unterm")); + turn.tool_calls.push(tool_call( + "write", + "{\"path\": \"a.txt\", \"content\": \"unterm", + )); let bad = turn.incomplete_tool_call(); assert_eq!(bad.map(|(name, _)| name), Some("write")); } @@ -369,7 +359,10 @@ mod tests { #[test] fn incomplete_tool_call_accepts_complete_json() { let mut turn = StreamedTurn::new(); - turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"done\"}")); + turn.tool_calls.push(tool_call( + "write", + "{\"path\": \"a.txt\", \"content\": \"done\"}", + )); assert!(turn.incomplete_tool_call().is_none()); } @@ -386,7 +379,10 @@ mod tests { // so it should still flag truncated JSON even though // `build_assistant_message` will later repair it. let mut turn = StreamedTurn::new(); - turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"unterm")); + turn.tool_calls.push(tool_call( + "write", + "{\"path\": \"a.txt\", \"content\": \"unterm", + )); // Even though it's repairable, raw parse should still fail assert!(serde_json::from_str::(&turn.tool_calls[0].arguments).is_err()); } diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 3c70597..a56ebbd 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -225,16 +225,16 @@ impl InputState { /// if none, close and return → otherwise fuzzy-match `query` against /// `files` via `nucleo-matcher`, keep the top 10 by score. pub fn open_mention_autocomplete(&mut self, files: &[String]) { + use nucleo_matcher::{Config, Matcher}; + use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; let Some((start, query)) = self.mention_query_at_cursor() else { self.close_autocomplete(); return; }; - use nucleo_matcher::{Config, Matcher}; - use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart); - let matches = pattern.match_list(files.iter(), &mut matcher); - self.autocomplete_candidates = matches.into_iter().take(10).map(|(f, _)| f.clone()).collect(); + let matched_files = pattern.match_list(files.iter(), &mut matcher); + self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect(); self.autocomplete_kind = AutocompleteKind::FileMention; self.mention_start = start; self.autocomplete_idx = 0; diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index 15acdea..58c72d5 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -167,7 +167,7 @@ impl AppStateRest { // async executor entirely. It is deliberately not joined -- startup // must not block on language server installation, and failures are // logged rather than surfaced, since editing still works without LSP. - if state.settings.lsp_auto_provision { + if state.settings.flags.lsp_auto_provision { let lsp_mgr = state.lsp_manager.clone(); let msg_queue = state.lsp_provision_msgs.clone(); std::thread::spawn(move || { diff --git a/src/app/state/runtime.rs b/src/app/state/runtime.rs index 17fa07c..c169594 100644 --- a/src/app/state/runtime.rs +++ b/src/app/state/runtime.rs @@ -1,9 +1,8 @@ //! Per-session runtime state: message history, pending tool queue, //! background bash jobs, lesson/review counters, and the `TurnEvent` //! stream emitted while an agent turn is in flight. - -use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; /// Cumulative token/latency counters for a session, persisted alongside it. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] diff --git a/src/app/state/snapshot.rs b/src/app/state/snapshot.rs index c238e86..8482363 100644 --- a/src/app/state/snapshot.rs +++ b/src/app/state/snapshot.rs @@ -1,6 +1,5 @@ //! Opaque, serializable snapshot of application state used for //! attach/daemon IPC transfer. - use serde::{Deserialize, Serialize}; /// A JSON-boxed snapshot of app state, opaque to the transport layer. diff --git a/src/app/state/types.rs b/src/app/state/types.rs index aae52a0..da6d3c0 100644 --- a/src/app/state/types.rs +++ b/src/app/state/types.rs @@ -1,10 +1,13 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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, //! tool execution model, and call origin tags. - use serde::{Deserialize, Serialize}; - /// Severity/category of a toast notification, used to pick its color. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ToastKind { diff --git a/src/app/subagent/auto.rs b/src/app/subagent/auto.rs index 05dc64d..b522861 100644 --- a/src/app/subagent/auto.rs +++ b/src/app/subagent/auto.rs @@ -15,27 +15,30 @@ //! wrote this file, let me check if it's correct before continuing"). //! - Background reviews catch broader concerns (missing tests, architectural //! drift, security issues) without blocking the main agent's flow. - -use std::path::Path; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::collections::VecDeque; use crate::app::state::runtime::TurnEvent; use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; -use crate::app::subagent::spawn::AgentDefinition; use crate::app::subagent::event::SubagentEvent; +use crate::app::subagent::spawn::AgentDefinition; +use std::collections::VecDeque; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; /// File extensions that should not trigger auto-review (config, lock, data). const SKIP_REVIEW_EXTENSIONS: &[&str] = &[ - ".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml", - ".svg", ".png", ".jpg", ".ico", ".woff", ".woff2", + ".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml", ".svg", ".png", ".jpg", ".ico", + ".woff", ".woff2", ]; /// File names that should not trigger auto-review. const SKIP_REVIEW_FILES: &[&str] = &[ - "Cargo.lock", "yarn.lock", "package-lock.json", - ".gitignore", ".env", ".env.example", + "Cargo.lock", + "yarn.lock", + "package-lock.json", + ".gitignore", + ".env", + ".env.example", ]; /// Prevents a second background subagent of the same kind from spawning @@ -137,8 +140,19 @@ fn is_production_code(path: &str) -> bool { .is_some_and(|ext| { matches!( ext, - "rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift" - | "c" | "cpp" | "h" | "hpp" + "rs" | "ts" + | "tsx" + | "js" + | "jsx" + | "go" + | "py" + | "java" + | "kt" + | "swift" + | "c" + | "cpp" + | "h" + | "hpp" ) }) } @@ -167,11 +181,8 @@ pub fn spawn_quick_review( file_path, ); - let def = AgentDefinition::new( - "quick-reviewer".to_string(), - "reviewer".to_string(), - ) - .with_system_prompt(prompt); + let def = AgentDefinition::new("quick-reviewer".to_string(), "reviewer".to_string()) + .with_system_prompt(prompt); let mut ctx = build_subagent_context(&def); ctx.session_dir = session_dir.to_path_buf(); @@ -187,7 +198,7 @@ pub fn spawn_quick_review( SubagentEvent::ToolResult { tool, .. } => { tracing::debug!("[auto-review] tool result: {}", tool); } - SubagentEvent::Completed { .. } => { + SubagentEvent::Completed => { tracing::debug!("[auto-review] completed"); } _ => {} @@ -277,7 +288,10 @@ pub fn spawn_background_test_gen( if file_paths.is_empty() { return; } - if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + if TEST_GEN_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight"); return; } @@ -306,8 +320,7 @@ pub fn spawn_background_test_gen( "test-generator".to_string(), "coder".to_string(), // needs write access ) - .with_system_prompt(prompt) - ; + .with_system_prompt(prompt); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag)); let message = match &result { @@ -347,7 +360,10 @@ pub fn spawn_background_arch_review( if file_paths.is_empty() { return; } - if ARCH_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + if ARCH_REVIEW_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight"); return; } @@ -366,12 +382,8 @@ pub fn spawn_background_arch_review( file_list, ); - let def = AgentDefinition::new( - "arch-reviewer".to_string(), - "reviewer".to_string(), - ) - .with_system_prompt(prompt) - ; + let def = AgentDefinition::new("arch-reviewer".to_string(), "reviewer".to_string()) + .with_system_prompt(prompt); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag)); let message = match &result { @@ -422,8 +434,13 @@ pub fn spawn_background_security_review( if prod_paths.is_empty() { return; } - if SECURITY_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { - tracing::debug!("[bg-security-review] skipped — a security-review run is already in flight"); + if SECURITY_REVIEW_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + tracing::debug!( + "[bg-security-review] skipped — a security-review run is already in flight" + ); return; } @@ -441,14 +458,11 @@ pub fn spawn_background_security_review( file_list, ); - let def = AgentDefinition::new( - "security-reviewer".to_string(), - "reviewer".to_string(), - ) - .with_system_prompt(prompt) - ; + let def = AgentDefinition::new("security-reviewer".to_string(), "reviewer".to_string()) + .with_system_prompt(prompt); - let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag)); + let result = + run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag)); let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); @@ -493,7 +507,13 @@ pub fn spawn_all_background( .filter(|p| is_production_code(p)) .cloned() .collect(); - spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone()); + spawn_background_test_gen( + &source_paths, + session_dir, + workspaces, + turn_events, + abort_flag.clone(), + ); // Background arch review: for all files that are reviewable let reviewable: Vec = file_paths @@ -501,10 +521,22 @@ pub fn spawn_all_background( .filter(|p| is_reviewable_path(p)) .cloned() .collect(); - spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone()); + spawn_background_arch_review( + &reviewable, + session_dir, + workspaces, + turn_events, + abort_flag.clone(), + ); // Background security review: only production source files - spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag); + spawn_background_security_review( + &source_paths, + session_dir, + workspaces, + turn_events, + abort_flag, + ); } #[cfg(test)] diff --git a/src/app/subagent/context.rs b/src/app/subagent/context.rs index fb010b6..7e82b94 100644 --- a/src/app/subagent/context.rs +++ b/src/app/subagent/context.rs @@ -1,9 +1,8 @@ //! Construction of a `SubagentContext` from an `AgentDefinition`, //! including the default read-only tool set for reviewer agents. - -use std::path::PathBuf; -use std::sync::{Arc, Mutex, atomic::AtomicBool}; use super::spawn::AgentDefinition; +use std::path::PathBuf; +use std::sync::{atomic::AtomicBool, Arc, Mutex}; /// Default read-only tool names granted to `role == "reviewer"` agents. pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"]; @@ -40,7 +39,10 @@ pub struct SubagentContext { pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext { let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { if def.role == "reviewer" { - REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect() + REVIEWER_ALLOWED + .iter() + .map(std::string::ToString::to_string) + .collect() } else { Vec::new() } diff --git a/src/app/subagent/division.rs b/src/app/subagent/division.rs index 4cb439c..c2755d6 100644 --- a/src/app/subagent/division.rs +++ b/src/app/subagent/division.rs @@ -22,24 +22,64 @@ pub mod tool_scope { /// authoritative "safe to deduplicate" classification, so there's a /// single list of read-only tool names in the codebase instead of two. pub const READ_TOOLS: &[&str] = &[ - "read", "grep", "glob", "search", "seqthink", "recall", - "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition", - "lsp_references", "read_findings", + "read", + "grep", + "glob", + "search", + "seqthink", + "recall", + "lsp_connect", + "lsp_diagnostics", + "lsp_hover", + "lsp_definition", + "lsp_references", + "read_findings", ]; const WRITE_TOOLS: &[&str] = &[ - "read", "grep", "glob", "search", "seqthink", "recall", - "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition", - "lsp_references", "read_findings", - "write", "edit", "bash", "todowrite", "todofinish", "remember", + "read", + "grep", + "glob", + "search", + "seqthink", + "recall", + "lsp_connect", + "lsp_diagnostics", + "lsp_hover", + "lsp_definition", + "lsp_references", + "read_findings", + "write", + "edit", + "bash", + "todowrite", + "todofinish", + "remember", ]; const FULL_TOOLS: &[&str] = &[ - "read", "grep", "glob", "search", "seqthink", "recall", - "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition", - "lsp_references", "read_findings", - "write", "edit", "bash", "todowrite", "todofinish", "remember", - "delete", "git_operator", "lsp_completion", "lsp_disconnect", + "read", + "grep", + "glob", + "search", + "seqthink", + "recall", + "lsp_connect", + "lsp_diagnostics", + "lsp_hover", + "lsp_definition", + "lsp_references", + "read_findings", + "write", + "edit", + "bash", + "todowrite", + "todofinish", + "remember", + "delete", + "git_operator", + "lsp_completion", + "lsp_disconnect", ]; /// Resolve a tier name to its concrete tool allowlist. @@ -98,7 +138,13 @@ mod tests { let read: HashSet<_> = tools_for(READ).into_iter().collect(); let write: HashSet<_> = tools_for(WRITE).into_iter().collect(); let full: HashSet<_> = tools_for(FULL).into_iter().collect(); - assert!(read.is_subset(&write), "read tier must be a subset of write tier"); - assert!(write.is_subset(&full), "write tier must be a subset of full tier"); + assert!( + read.is_subset(&write), + "read tier must be a subset of write tier" + ); + assert!( + write.is_subset(&full), + "write tier must be a subset of full tier" + ); } } diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs index 4bbf7c0..41d6a78 100644 --- a/src/app/subagent/engine.rs +++ b/src/app/subagent/engine.rs @@ -632,7 +632,7 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> shared_text.truncate(50_000); shared_text.push_str("\n...[truncated]"); } - f.push(format!("[Auto-Shared] Sibling drone executed '{}' with args {}:\n{}", tool_name, args_json, shared_text)); + f.push(format!("[Auto-Shared] Sibling drone executed '{tool_name}' with args {args_json}:\n{shared_text}")); } } } diff --git a/src/app/subagent/event.rs b/src/app/subagent/event.rs index 2f195ad..37aeb53 100644 --- a/src/app/subagent/event.rs +++ b/src/app/subagent/event.rs @@ -1,6 +1,5 @@ //! Event variants that a running subagent can emit to its parent via the //! shared mpsc channel. - use serde_json::Value; /// Progress and outcome events emitted by `run_subagent` as it processes @@ -8,29 +7,20 @@ use serde_json::Value; #[derive(Debug, Clone)] pub enum SubagentEvent { StepCompleted { - #[allow(dead_code)] - step: usize, - #[allow(dead_code)] output: String, }, StepFailed { step: usize, error: String, }, - Completed { - #[allow(dead_code)] - output: String, - }, + Completed, ToolCall { tool: String, - #[allow(dead_code)] args: Value, }, ToolResult { tool: String, args: Value, - #[allow(dead_code)] - output: String, }, Progress(String), /// Token usage reported by the LLM after one streaming call inside the diff --git a/src/app/subagent/mod.rs b/src/app/subagent/mod.rs index 2236d66..cf9c55e 100644 --- a/src/app/subagent/mod.rs +++ b/src/app/subagent/mod.rs @@ -1,6 +1,5 @@ //! Subagent management: spawning, context building, engine loop, and //! progress events. - pub mod auto; pub mod context; pub mod division; diff --git a/src/app/subagent/spawn.rs b/src/app/subagent/spawn.rs index 18ca61d..32a3863 100644 --- a/src/app/subagent/spawn.rs +++ b/src/app/subagent/spawn.rs @@ -1,6 +1,5 @@ //! `AgentDefinition` -- declarative specification for instantiating a //! subagent from workflow scripts or programmatic calls. - use serde::{Deserialize, Serialize}; /// Declarative specification for instantiating a subagent: name, role, @@ -29,13 +28,6 @@ impl AgentDefinition { } } - /// Builder method: limit this agent to at most `steps` LLM calls. - #[allow(dead_code)] - pub fn with_max_steps(mut self, steps: usize) -> Self { - self.max_steps = Some(steps); - self - } - /// Builder method: set the system prompt for this agent. pub fn with_system_prompt(mut self, prompt: String) -> Self { self.system_prompt = Some(prompt); diff --git a/src/app/workflow/docs.rs b/src/app/workflow/docs.rs index 56ef143..5340e31 100644 --- a/src/app/workflow/docs.rs +++ b/src/app/workflow/docs.rs @@ -6,11 +6,10 @@ //! choice, so this step is plain Rust — not an LLM call, not a cycle the //! Core Intelligence can omit or reshape — and always runs after any //! hive-mind convergence completes. - -use std::path::{Path, PathBuf}; -use std::fmt::Write as _; use crate::app::workflow::hive_mind::NodeReport; use crate::model::memory::Memory; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; /// Write a markdown report of one hive-mind convergence to /// `/docs/runs/-.md`. @@ -42,22 +41,31 @@ pub fn write_hive_mind_convergence( } /// Render a hive-mind convergence as a markdown document. -fn render_report(user_request: &str, ts_millis: i64, reports: &[NodeReport], consensus: &str) -> String { +fn render_report( + user_request: &str, + ts_millis: i64, + reports: &[NodeReport], + consensus: &str, +) -> String { let mut out = String::new(); - writeln!(out, "# The Hive converges: {user_request}").unwrap(); - writeln!(out, "\nTimestamp (ms): {ts_millis}\n").unwrap(); + let _ = writeln!(out, "# The Hive converges: {user_request}"); + let _ = writeln!(out, "\nTimestamp (ms): {ts_millis}\n"); - let cycle_count = reports.iter().map(|r| r.cycle_index).max().map_or(0, |m| m + 1); + let cycle_count = reports + .iter() + .map(|r| r.cycle_index) + .max() + .map_or(0, |m| m + 1); for cycle_index in 0..cycle_count { - writeln!(out, "## Cycle {cycle_index}\n").unwrap(); + let _ = writeln!(out, "## Cycle {cycle_index}\n"); for r in reports.iter().filter(|r| r.cycle_index == cycle_index) { - writeln!(out, "### {}\n", r.node_id).unwrap(); - writeln!(out, "{}\n", r.output).unwrap(); + let _ = writeln!(out, "### {}\n", r.node_id); + let _ = writeln!(out, "{}\n", r.output); } } - writeln!(out, "## The Hive's Verdict\n").unwrap(); - writeln!(out, "{consensus}\n").unwrap(); + let _ = writeln!(out, "## The Hive's Verdict\n"); + let _ = writeln!(out, "{consensus}\n"); out } @@ -70,10 +78,14 @@ mod tests { let tmp = std::env::temp_dir().join(format!("zesdex-docs-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); - let reports = vec![ - NodeReport { node_id: "Node-0-0".to_string(), cycle_index: 0, output: "found the bug".to_string() }, - ]; - let path = write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check").unwrap(); + let reports = vec![NodeReport { + node_id: "Node-0-0".to_string(), + cycle_index: 0, + output: "found the bug".to_string(), + }]; + let path = + write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check") + .unwrap(); assert!(path.starts_with(tmp.join("docs").join("runs"))); let content = std::fs::read_to_string(&path).unwrap(); diff --git a/src/app/workflow/engine.rs b/src/app/workflow/engine.rs index b72095d..7a01834 100644 --- a/src/app/workflow/engine.rs +++ b/src/app/workflow/engine.rs @@ -13,12 +13,14 @@ //! `Arc>>` threaded through `execute_primitive` and //! `spawn_single_agent` rather than a global static, preventing data //! leaks between concurrent workflow runs. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; -use std::time::Duration; -use serde::{Deserialize, Serialize}; use super::script::{ScriptPrimitive, WorkflowScript}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::time::Duration; /// The lifecycle state of an agent within a workflow run. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -100,17 +102,31 @@ pub type LiveStateFn = Arc; /// Return: the agent's text output, or an error on failure. fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) -> String { let details = match tool { - "read" | "view_file" | "write" | "write_to_file" | "edit" | "replace_file_content" | "multi_replace_file_content" | "delete" => { - args.get("path") - .or_else(|| args.get("TargetFile")) - .or_else(|| args.get("AbsolutePath")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - } + "read" + | "view_file" + | "write" + | "write_to_file" + | "edit" + | "replace_file_content" + | "multi_replace_file_content" + | "delete" => args + .get("path") + .or_else(|| args.get("TargetFile")) + .or_else(|| args.get("AbsolutePath")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), "grep" | "grep_search" => { - let pattern = args.get("pattern").or_else(|| args.get("Query")).and_then(|v| v.as_str()).unwrap_or(""); - let path = args.get("path").or_else(|| args.get("SearchPath")).and_then(|v| v.as_str()).unwrap_or(""); + let pattern = args + .get("pattern") + .or_else(|| args.get("Query")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let path = args + .get("path") + .or_else(|| args.get("SearchPath")) + .and_then(|v| v.as_str()) + .unwrap_or(""); if path.is_empty() { format!("\"{pattern}\"") } else { @@ -127,31 +143,44 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) } } "bash" | "run_command" => { - let cmd = args.get("command").or_else(|| args.get("CommandLine")).and_then(|v| v.as_str()).unwrap_or(""); + let cmd = args + .get("command") + .or_else(|| args.get("CommandLine")) + .and_then(|v| v.as_str()) + .unwrap_or(""); if cmd.len() > 60 { format!("\"{}...\"", &cmd[..57]) } else { format!("\"{cmd}\"") } } - "recall" => { - args.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string() - } - "remember" => { - args.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string() - } - "dir_list" | "list_dir" => { - args.get("DirectoryPath").or_else(|| args.get("path")).and_then(|v| v.as_str()).unwrap_or("").to_string() - } + "recall" => args + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + "remember" => args + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + "dir_list" | "list_dir" => args + .get("DirectoryPath") + .or_else(|| args.get("path")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), _ => { - if args.is_object() && !args.as_object().unwrap().is_empty() { - args.as_object().unwrap().values() - .find_map(|v| v.as_str()) - .unwrap_or("") - .to_string() - } else { - String::new() + if let Some(obj) = args.as_object() { + if !obj.is_empty() { + return obj + .values() + .find_map(|v| v.as_str()) + .unwrap_or("") + .to_string(); + } } + String::new() } }; @@ -181,7 +210,6 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) /// a stuck stage from blocking the entire pipeline forever. /// /// 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( agent_id: &str, agent_name: &str, @@ -338,10 +366,13 @@ fn spawn_single_agent( ); } } - SubagentEvent::Completed { .. } => { + SubagentEvent::Completed => { tracing::debug!("[subagent] completed"); } - SubagentEvent::Usage { tokens_in, tokens_out } => { + SubagentEvent::Usage { + tokens_in, + tokens_out, + } => { tracing::debug!("[subagent] usage: {} in, {} out", tokens_in, tokens_out); } } @@ -349,7 +380,10 @@ fn spawn_single_agent( }); // Check abort before even starting the subagent. - if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) { + if abort_flag + .as_ref() + .is_some_and(|f| f.load(Ordering::SeqCst)) + { anyhow::bail!("subagent '{agent_name}' aborted before start"); } @@ -380,9 +414,7 @@ fn spawn_single_agent( )); } if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) { - break Err(anyhow::anyhow!( - "subagent '{bg_name}' aborted by user", - )); + break Err(anyhow::anyhow!("subagent '{bg_name}' aborted by user")); } } } else { @@ -391,9 +423,7 @@ fn spawn_single_agent( 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", - )); + break Err(anyhow::anyhow!("subagent '{bg_name}' aborted by user")); } } }; @@ -467,8 +497,6 @@ type ParallelResult = (usize, anyhow::Result>); /// /// Return: a `Vec` of all agent outputs (or error strings) in /// the order they were submitted. -#[allow(clippy::too_many_arguments)] -#[allow(clippy::ref_option, clippy::too_many_lines)] pub fn execute_primitive( primitive: &ScriptPrimitive, args: &HashMap, @@ -501,7 +529,20 @@ pub fn execute_primitive( let resolved = resolve_template(prompt, &resolved_args); let agent_id = uuid::Uuid::new_v4().to_string(); let agent_name = resolved.chars().take(40).collect::(); - match spawn_single_agent(&agent_id, &agent_name, &resolved, "coder", None, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) { + match spawn_single_agent( + &agent_id, + &agent_name, + &resolved, + "coder", + None, + &findings_snapshot, + findings, + abort_flag, + live, + session_dir, + workspaces, + timeout_ms, + ) { Ok(text) => Ok(vec![text]), Err(e) => { if continue_on_error { @@ -513,7 +554,11 @@ pub fn execute_primitive( } } - ScriptPrimitive::ScopedAgent { prompt, node_id, tool_scope } => { + ScriptPrimitive::ScopedAgent { + prompt, + node_id, + tool_scope, + } => { let mut resolved_args = args.clone(); let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default(); if !resolved_args.contains_key("findings") { @@ -535,9 +580,24 @@ pub fn execute_primitive( tracing::debug!("[hive] deploying drone {node_id}: {truncated}"); let agent_name = format!("{node_id}: {truncated}"); let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope); - match spawn_single_agent(&agent_id, &agent_name, &resolved, node_id, Some(allowed_tools), &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) { + match spawn_single_agent( + &agent_id, + &agent_name, + &resolved, + node_id, + Some(allowed_tools), + &findings_snapshot, + findings, + abort_flag, + live, + session_dir, + workspaces, + timeout_ms, + ) { Ok(text) => { - tracing::debug!("[hive] drone {node_id} completed — merging into collective state"); + tracing::debug!( + "[hive] drone {node_id} completed — merging into collective state" + ); // Merge this drone's complete output into the Hive's // collective state the instant it finishes — not after // the whole parallel cohort completes. Any sibling drone @@ -567,8 +627,7 @@ pub fn execute_primitive( // Each branch shares the same `findings` Arc so note_finding // calls within any branch are visible to all other branches. let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1))); - let results: Arc>> = - Arc::new(Mutex::new(Vec::new())); + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); let handles: Vec<_> = scripts .iter() @@ -589,7 +648,10 @@ pub fn execute_primitive( std::thread::spawn(move || { let _permit = sem.acquire(); let result = execute_primitive( - &script, &args, cap, continue_on_error, + &script, + &args, + cap, + continue_on_error, &abort, live_clone.as_ref(), &session_dir, @@ -608,7 +670,9 @@ pub fn execute_primitive( let _ = handle.join(); } - let mut locked = results.lock().map_err(|_| anyhow::anyhow!("parallel results lock poisoned"))?; + let mut locked = results + .lock() + .map_err(|_| anyhow::anyhow!("parallel results lock poisoned"))?; locked.sort_by_key(|(idx, _)| *idx); let mut all = Vec::new(); for (_, res) in locked.drain(..) { @@ -635,14 +699,28 @@ pub fn execute_primitive( for (idx, script) in scripts.iter().enumerate() { // 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 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) { + match execute_primitive( + script, + args, + concurrency_cap, + continue_on_error, + abort_flag, + live, + session_dir, + workspaces, + findings, + timeout_ms, + ) { Ok(outputs) => all.extend(outputs), Err(e) => { if continue_on_error { @@ -656,9 +734,21 @@ pub fn execute_primitive( Ok(all) } - ScriptPrimitive::Phase { name: _name, script } => { - execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms) - } + ScriptPrimitive::Phase { + name: _name, + script, + } => execute_primitive( + script, + args, + concurrency_cap, + continue_on_error, + abort_flag, + live, + session_dir, + workspaces, + findings, + timeout_ms, + ), } } @@ -687,7 +777,6 @@ pub fn run_workflow( /// `spawn_agents` invocations remain fully isolated. /// /// Return: a human-readable summary string. -#[allow(clippy::ref_option)] pub fn run_workflow_tracked( script: &WorkflowScript, args: &HashMap, @@ -704,9 +793,15 @@ pub fn run_workflow_tracked( let findings = Arc::new(Mutex::new(Vec::new())); let results = execute_primitive( - &script.script, args, concurrency_cap, - script.options.continue_on_error, abort_flag, live, - session_dir, workspaces, &findings, + &script.script, + args, + concurrency_cap, + script.options.continue_on_error, + abort_flag, + live, + session_dir, + workspaces, + &findings, script.options.timeout_ms, )?; diff --git a/src/app/workflow/hive_mind.rs b/src/app/workflow/hive_mind.rs index efcf980..6904326 100644 --- a/src/app/workflow/hive_mind.rs +++ b/src/app/workflow/hive_mind.rs @@ -25,12 +25,14 @@ //! Synthesis node reads the complete collective state and converges it //! into one unified voice — returned to LO and persisted to docs/runs/*.md. //! ``` - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; -use serde::Deserialize; +use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn}; use crate::app::workflow::script::ScriptPrimitive; -use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; /// One directive the Hive's Core Intelligence issues to a drone within a /// cognitive cycle. A drone's sole identity is its directive and access tier. @@ -85,60 +87,41 @@ pub const HIVE_MIND_CONSENSUS_TAG: &str = "[The Hive speaks]"; /// Return: `true` if any prior system message begins with /// `HIVE_MIND_CONSENSUS_TAG`. pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator) -> bool { - system_message_bodies.into_iter().any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG)) + system_message_bodies + .into_iter() + .any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG)) } /// Build the live-state callback that forwards each drone's status to the /// TUI panel so LO can watch the Hive work. fn build_live( - turn_events: Option<&Arc>>>, + turn_events: Option< + &Arc>>, + >, ) -> Option { 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(40).collect::(); - 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, - }); - } - }); + let f: LiveStateFn = Arc::new( + move |_agent_id: String, agent_name: String, status: AgentStatus| { + let display_name = agent_name.chars().take(40).collect::(); + 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 }) } -/// Deploy the Hive: execute a cognitive cycle plan authored by the Core -/// Intelligence. Each cycle spawns drones (anonymous processing nodes) in -/// parallel. Every drone's complete output merges into the Hive's -/// collective state the instant it finishes, and a final synthesis node -/// reconciles the entire collective state into one unified voice. +/// Context struct threaded through all Hive cycle execution. /// -/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent` -/// per directive, tagged with a system-assigned `node_id` (the Hive's -/// coordinate system, never an LLM-chosen name) → run them as a `Parallel` -/// block via `execute_primitive`, which merges each drone's output into the -/// Hive's shared collective-state Arc the instant that drone completes, not -/// after the whole cohort finishes → record `NodeReport`s → proceed to the -/// next cycle. After all cycles: spawn one more read-only synthesis node -/// whose directive is to converge the complete collective state into a -/// single consensus — the Hive becoming one voice — not list what each -/// drone said. -/// -/// Concurrency per cycle and the per-drone timeout both come from -/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`) -/// rather than a hardcoded cap/no-timeout — a stuck drone can no longer -/// stall the entire Hive forever. -/// -/// Return: `(consensus, all_node_reports)` on success. `consensus` is the -/// synthesis node's converged output — what the Core Intelligence actually -/// hears from the Hive. `all_node_reports` is the complete per-drone record. -/// -/// The convergence doc under `docs/runs/*.md` is written unconditionally -/// before this function returns — even when synthesis itself fails — so a -/// synthesis error never discards the work already done by cycle drones. -/// Callers must not write their own copy of this doc. +/// Carries the user request, shared collective state, concurrency limits, +/// abort flag, live-status callback, session/workspace paths, and per-drone +/// timeout so individual cycle functions don't need long parameter lists. struct CycleCtx<'a> { user_request: &'a str, collective_state: &'a Arc>>, @@ -154,6 +137,8 @@ struct CycleCtx<'a> { /// /// Flow: map cycle directives to `ScopedAgent` primitives -> group in a Parallel /// phase block -> run block via `execute_primitive` -> return reports. +/// +/// Return: `Ok(Vec)` with one report per directive in submission order. fn execute_cycle( cycle_index: usize, directives: &[NodeDirective], @@ -249,12 +234,44 @@ fn execute_cycle( Ok(reports) } +/// Deploy the Hive: execute a cognitive cycle plan authored by the Core +/// Intelligence. Each cycle spawns drones (anonymous processing nodes) in +/// parallel. Every drone's complete output merges into the Hive's +/// collective state the instant it finishes, and a final synthesis node +/// reconciles the entire collective state into one unified voice. +/// +/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent` +/// per directive, tagged with a system-assigned `node_id` (the Hive's +/// coordinate system, never an LLM-chosen name) → run them as a `Parallel` +/// block via `execute_primitive`, which merges each drone's output into the +/// Hive's shared collective-state Arc the instant that drone completes, not +/// after the whole cohort finishes → record `NodeReport`s → proceed to the +/// next cycle. After all cycles: spawn one more read-only synthesis node +/// whose directive is to converge the complete collective state into a +/// single consensus — the Hive becoming one voice — not list what each +/// drone said. +/// +/// Concurrency per cycle and the per-drone timeout both come from +/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`) +/// rather than a hardcoded cap/no-timeout — a stuck drone can no longer +/// stall the entire Hive forever. +/// +/// Return: `(consensus, all_node_reports)` on success. `consensus` is the +/// synthesis node's converged output — what the Core Intelligence actually +/// hears from the Hive. `all_node_reports` is the complete per-drone record. +/// +/// The convergence doc under `docs/runs/*.md` is written unconditionally +/// before this function returns — even when synthesis itself fails — so a +/// synthesis error never discards the work already done by cycle drones. +/// Callers must not write their own copy of this doc. pub fn run_hive_mind( user_request: &str, plan: &CognitiveCyclePlan, session_dir: &std::path::Path, workspaces: &[std::path::PathBuf], - turn_events: Option<&Arc>>>, + turn_events: Option< + &Arc>>, + >, abort_flag: Option<&Arc>, ) -> anyhow::Result<(String, Vec)> { if plan.cycles.is_empty() { @@ -288,20 +305,25 @@ pub fn run_hive_mind( anyhow::bail!("the Hive was recalled by LO before cycle {cycle_index}"); } - tracing::info!("[hive-mind] cycle {cycle_index} deploying {} drone(s)", directives.len()); + tracing::info!( + "[hive-mind] cycle {cycle_index} deploying {} drone(s)", + directives.len() + ); - let mut cycle_reports = execute_cycle( - cycle_index, - directives, - &ctx, - )?; + let mut cycle_reports = execute_cycle(cycle_index, directives, &ctx)?; reports.append(&mut cycle_reports); } tracing::info!("[hive-mind] all cycles complete — the Hive begins convergence"); let consensus_result = synthesize_consensus( - user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, node_timeout_ms, + user_request, + session_dir, + workspaces, + &collective_state, + live.as_ref(), + abort_flag, + node_timeout_ms, ); // Guaranteed documentation: write the convergence doc for whatever @@ -311,13 +333,19 @@ pub fn run_hive_mind( // CLAUDE.md promises for every convergence. let doc_consensus = match &consensus_result { Ok(c) => c.clone(), - Err(e) => format!( - "The Hive's convergence fractured: {e}. Partial node reports above.", - ), + Err(e) => format!("The Hive's convergence fractured: {e}. Partial node reports above."), }; if let Some(workspace_root) = workspaces.first() { - match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &doc_consensus) { - Ok(path) => tracing::info!("[hive-mind] the Hive's convergence written to {}", path.display()), + match crate::app::workflow::docs::write_hive_mind_convergence( + workspace_root, + user_request, + &reports, + &doc_consensus, + ) { + Ok(path) => tracing::info!( + "[hive-mind] the Hive's convergence written to {}", + path.display() + ), Err(e) => tracing::warn!("[hive-mind] the Hive's convergence report failed: {e}"), } } @@ -373,7 +401,16 @@ fn synthesize_consensus( let args: HashMap = HashMap::new(); let abort_owned: Option> = abort_flag.cloned(); let results = execute_primitive( - &synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, node_timeout_ms, + &synthesis, + &args, + 1, + false, + &abort_owned, + live, + session_dir, + workspaces, + collective_state, + node_timeout_ms, )?; Ok(results.into_iter().next().unwrap_or_default()) } @@ -402,16 +439,31 @@ pub fn is_complex_request(request: &str) -> bool { // Single-line simple update patterns let lower = trimmed.to_lowercase(); let negative_keywords = [ - "simple", "trivial", "typo", "just a", "only a", "minor", - "quick", "tiny", "small fix", "rename", "nitpick", - "cosmetic", "formatting", "spelling", "grammar", - "bump", "version bump", "update comment", + "simple", + "trivial", + "typo", + "just a", + "only a", + "minor", + "quick", + "tiny", + "small fix", + "rename", + "nitpick", + "cosmetic", + "formatting", + "spelling", + "grammar", + "bump", + "version bump", + "update comment", ]; if negative_keywords.iter().any(|k| lower.contains(k)) { return false; } // Multi-line/multi-sentence → likely complex - let sentences = trimmed.split(['.', '!', '?']) + let sentences = trimmed + .split(['.', '!', '?']) .filter(|s| !s.trim().is_empty()) .count(); if sentences >= 3 { @@ -419,11 +471,29 @@ pub fn is_complex_request(request: &str) -> bool { } // Positive complexity keywords let complexity_keywords = [ - "refactor", "redesign", "architecture", "feature", "implement", - "migrate", "restructure", "rewrite", "new module", "new component", - "scaffold", "multi", "multiple files", "api", "endpoint", - "integration", "system", "workflow", "pipeline", "database", - "authentication", "authorization", "full stack", + "refactor", + "redesign", + "architecture", + "feature", + "implement", + "migrate", + "restructure", + "rewrite", + "new module", + "new component", + "scaffold", + "multi", + "multiple files", + "api", + "endpoint", + "integration", + "system", + "workflow", + "pipeline", + "database", + "authentication", + "authorization", + "full stack", ]; complexity_keywords.iter().any(|k| lower.contains(k)) } @@ -445,7 +515,9 @@ mod tests { #[test] fn test_is_complex_request_multi_sentence() { - assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three.")); + assert!(is_complex_request( + "This is sentence one. This is sentence two. This is sentence three." + )); } #[test] @@ -456,9 +528,7 @@ mod tests { #[test] fn test_default_access_is_read() { - let d: NodeDirective = serde_json::from_str( - r#"{"directive": "write tests"}"# - ).unwrap(); + let d: NodeDirective = serde_json::from_str(r#"{"directive": "write tests"}"#).unwrap(); assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ); } @@ -468,14 +538,16 @@ mod tests { // "role" key, if an LLM emits one out of old habit, is simply // ignored rather than required or preserved. let d: NodeDirective = serde_json::from_str( - r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"# - ).unwrap(); + r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"#, + ) + .unwrap(); assert_eq!(d.directive, "plan the migration"); } #[test] fn test_cognitive_cycle_plan_arbitrary_shape() { - let plan: CognitiveCyclePlan = serde_json::from_str(r#"{ + let plan: CognitiveCyclePlan = serde_json::from_str( + r#"{ "cycles": [ [{"directive": "scan the codebase topology", "access": "read"}], [ @@ -484,7 +556,9 @@ mod tests { ], [{"directive": "cut the release", "access": "full"}] ] - }"#).unwrap(); + }"#, + ) + .unwrap(); assert_eq!(plan.cycles.len(), 3); assert_eq!(plan.cycles[1].len(), 2); } @@ -502,9 +576,12 @@ mod tests { fn test_run_hive_mind_aborts_before_spawning_when_flag_preset() { // The abort check runs before execute_primitive for cycle 0, so a // pre-set abort flag must short-circuit without any LLM/network call. - let plan: CognitiveCyclePlan = serde_json::from_str(r#"{ + let plan: CognitiveCyclePlan = serde_json::from_str( + r#"{ "cycles": [[{"directive": "whatever", "access": "read"}]] - }"#).unwrap(); + }"#, + ) + .unwrap(); let tmp = std::env::temp_dir(); let abort_flag = Arc::new(AtomicBool::new(true)); let err = run_hive_mind("do something", &plan, &tmp, &[], None, Some(&abort_flag)) @@ -526,12 +603,16 @@ mod tests { "you are a helpful assistant".to_string(), format!("{HIVE_MIND_CONSENSUS_TAG}\nthe bug is a null check"), ]; - assert!(hive_mind_already_ran(bodies.iter().map(std::string::String::as_str))); + assert!(hive_mind_already_ran( + bodies.iter().map(std::string::String::as_str) + )); } #[test] fn hive_mind_already_ran_false_when_no_prior_convergence() { let bodies = ["you are a helpful assistant".to_string()]; - assert!(!hive_mind_already_ran(bodies.iter().map(std::string::String::as_str))); + assert!(!hive_mind_already_ran( + bodies.iter().map(std::string::String::as_str) + )); } } diff --git a/src/app/workflow/mod.rs b/src/app/workflow/mod.rs index baff75d..ba4ceeb 100644 --- a/src/app/workflow/mod.rs +++ b/src/app/workflow/mod.rs @@ -1,7 +1,6 @@ //! Workflow orchestration: a script interpreter that runs pipeline/parallel //! primitives across multiple subagent instances. - -pub mod hive_mind; pub mod docs; pub mod engine; +pub mod hive_mind; pub mod script; diff --git a/src/app/workflow/script.rs b/src/app/workflow/script.rs index d9665d9..6d2d77a 100644 --- a/src/app/workflow/script.rs +++ b/src/app/workflow/script.rs @@ -1,6 +1,5 @@ //! Script primitives for the workflow engine: agent invocation, parallel //! execution, pipelines, and phases. - use serde::{Deserialize, Serialize}; /// A workflow script primitive — can be a single agent, a parallel fan-out, diff --git a/src/controller/command.rs b/src/controller/command.rs index a4816f9..4834d0a 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -11,10 +11,7 @@ pub enum Command { ClearConfirm, Login { provider: String }, Edit(String), - McpAdd { - name: String, - command: String, - }, + McpAdd { name: String, command: String }, ModelList, Compact, TodoOpen, @@ -44,13 +41,15 @@ pub fn parse_command(text: &str) -> Command { "/quit" => Command::Quit, "/clear" if arg1.is_empty() => Command::ClearConfirm, "/clear" => Command::Clear, - "/login" if arg1.is_empty() => Command::Login { provider: String::new() }, - "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, + "/login" if arg1.is_empty() => Command::Login { + provider: String::new(), + }, + "/login" if !arg1.is_empty() => Command::Login { + provider: arg1.to_string(), + }, "/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()), "/edit" => Command::Edit(".".to_string()), - "/mcp" if arg1.is_empty() => { - Command::McpOpen - } + "/mcp" if arg1.is_empty() => Command::McpOpen, "/mcp" if arg1 == "add" && !arg2.is_empty() => { let rest = arg2.trim(); if let Some(space) = rest.find(' ') { @@ -58,7 +57,10 @@ pub fn parse_command(text: &str) -> Command { let command = rest[space + 1..].trim().to_string(); Command::McpAdd { name, command } } else { - Command::McpAdd { name: rest.to_string(), command: String::new() } + Command::McpAdd { + name: rest.to_string(), + command: String::new(), + } } } "/model" => Command::ModelList, diff --git a/src/controller/input.rs b/src/controller/input.rs index 3a755c3..c3ce658 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -1,7 +1,6 @@ //! Key event dispatcher: maps crossterm `KeyEvent` values into `Action` //! variants, with special handling for overlays, auto-complete, and the //! inline editor. - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::mode; @@ -22,7 +21,6 @@ use crate::controller::command::parse_command; /// Why: when Editor overlay is active, all key events are consumed by the /// editor handler and never reach the main action dispatch. Return `Vec` /// 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 { // While Editor overlay is active, route input directly to the editor handler if state.misc.overlay == Overlay::Editor { @@ -82,27 +80,39 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { KeyCode::Up => { let items = crate::app::mode::learning::get_learning_items(state); let n = items.len(); - state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; + state.misc.selected_index = if state.misc.selected_index == 0 { + n.saturating_sub(1) + } else { + state.misc.selected_index - 1 + }; state.dirty = true; return vec![]; } KeyCode::Down => { let items = crate::app::mode::learning::get_learning_items(state); let n = items.len(); - state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; + state.misc.selected_index = if n == 0 { + 0 + } else { + (state.misc.selected_index + 1) % n + }; state.dirty = true; return vec![]; } KeyCode::Enter | KeyCode::Char('a') => { let items = crate::app::mode::learning::get_learning_items(state); - if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) { + if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = + items.get(state.misc.selected_index) + { return vec![Action::LessonAccept { name: name.clone() }]; } return vec![]; } KeyCode::Char('r') => { let items = crate::app::mode::learning::get_learning_items(state); - if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) { + if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = + items.get(state.misc.selected_index) + { return vec![Action::LessonReject { name: name.clone() }]; } return vec![]; @@ -133,7 +143,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::CloseOverlay] } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let last_assistant = state.transcript_cache.messages.iter() + let last_assistant = state + .transcript_cache + .messages + .iter() .rev() .find(|m| m.role == crate::dto::chat::message::Role::Assistant); match last_assistant { @@ -194,18 +207,24 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); Vec::new() - } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); - state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; + state.misc.selected_index = if state.misc.selected_index == 0 { + n.saturating_sub(1) + } else { + state.misc.selected_index - 1 + }; state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::ModelSelector { let n = state.app_config.providers.len(); - state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; + state.misc.selected_index = if state.misc.selected_index == 0 { + n.saturating_sub(1) + } else { + state.misc.selected_index - 1 + }; state.dirty = true; Vec::new() - } else if key.modifiers.contains(KeyModifiers::CONTROL) { vec![Action::ScrollUp] } else { @@ -220,18 +239,24 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); Vec::new() - } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); - state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; + state.misc.selected_index = if n == 0 { + 0 + } else { + (state.misc.selected_index + 1) % n + }; state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::ModelSelector { let n = state.app_config.providers.len(); - state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; + state.misc.selected_index = if n == 0 { + 0 + } else { + (state.misc.selected_index + 1) % n + }; state.dirty = true; Vec::new() - } else if key.modifiers.contains(KeyModifiers::CONTROL) { vec![Action::ScrollDown] } else { @@ -287,7 +312,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { if state.input.buffer.starts_with('/') { state.input.open_autocomplete(); } else if state.input.mention_query_at_cursor().is_some() { - state.input.open_mention_autocomplete(&state.mention_index.snapshot()); + state + .input + .open_mention_autocomplete(&state.mention_index.snapshot()); } Vec::new() } @@ -326,7 +353,10 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { if text.is_empty() { state.settings.api_keys.remove(&state.settings.provider); } else { - state.settings.api_keys.insert(state.settings.provider.clone(), text.clone()); + state + .settings + .api_keys + .insert(state.settings.provider.clone(), text.clone()); } let _ = state.settings.save(); state.input.buffer.clear(); @@ -354,14 +384,24 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { if let Some(provider) = providers.get(state.misc.selected_index) { if let Some(cfg) = state.app_config.providers.get(provider) { let model = cfg.default_model.clone().unwrap_or_else(|| { - 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() }); state.settings.provider.clone_from(provider); state.settings.model.clone_from(&model); if let Some(ref key) = cfg.default_api_key { - 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()) { + 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()) + { state.settings.api_keys.insert(provider.clone(), env_key); } let _ = state.settings.save(); @@ -418,14 +458,23 @@ mod tests { crate::dto::chat::message::Role::Assistant, "second reply".to_string(), )); - handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state); - assert_eq!(state.misc.pending_clipboard_copy, Some("second reply".to_string())); + handle_key( + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), + &mut state, + ); + assert_eq!( + state.misc.pending_clipboard_copy, + Some("second reply".to_string()) + ); } #[test] fn ctrl_y_with_no_assistant_message_pushes_info_toast() { let mut state = test_state(); - handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state); + handle_key( + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), + &mut state, + ); assert!(state.misc.pending_clipboard_copy.is_none()); assert_eq!(state.misc.toasts.len(), 1); } diff --git a/src/controller/mod.rs b/src/controller/mod.rs index a5e0fdd..629ab94 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -1,4 +1,3 @@ //! Keyboard input handling and command parsing for the TUI. - pub mod command; pub mod input; diff --git a/src/dto/chat/message.rs b/src/dto/chat/message.rs index 0501d03..e6068ca 100644 --- a/src/dto/chat/message.rs +++ b/src/dto/chat/message.rs @@ -1,6 +1,5 @@ //! Chat message types shared across the DTO layer: `Role` and `ChatMessage` //! with convenience constructors. - use serde::{Deserialize, Serialize}; /// The conversation participant who authored a message. @@ -17,6 +16,21 @@ pub enum Role { } impl Role { + /// Return the role as a lowercase string. + pub fn as_str(&self) -> &'static str { + match self { + Role::User => "user", + Role::Assistant => "assistant", + Role::System => "system", + Role::Tool => "tool", + } + } +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } } /// A single message in a conversation, compatible with the OpenAI/Anthropic diff --git a/src/dto/chat/mod.rs b/src/dto/chat/mod.rs index 2cf938e..a43b29a 100644 --- a/src/dto/chat/mod.rs +++ b/src/dto/chat/mod.rs @@ -1,4 +1,3 @@ //! Chat DTO submodules: message roles/content and tool-call structures. - pub mod message; pub mod tool; diff --git a/src/dto/chat/tool.rs b/src/dto/chat/tool.rs index e4c5b3e..08d2ebb 100644 --- a/src/dto/chat/tool.rs +++ b/src/dto/chat/tool.rs @@ -7,7 +7,6 @@ //! //! Why: kept separate from `dto::provider` because tool calls are a property //! of a chat *message*, not of the request/response envelope. - use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -41,10 +40,7 @@ mod tests { #[test] fn repair_json_bracket_then_brace() { // `[` opened first → `]` must close first, then `}` - assert_eq!( - repair_json("[[1, 2, {\"a\": 3"), - "[[1, 2, {\"a\": 3}]]" - ); + assert_eq!(repair_json("[[1, 2, {\"a\": 3"), "[[1, 2, {\"a\": 3}]]"); } #[test] @@ -212,7 +208,8 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value { // Attempt 2: strip control chars (0x00-0x1F except \t, \n) // that some LLM providers emit as literal bytes in JSON strings // (e.g. multi-line commit messages), then retry. - let cleaned: String = s.chars() + let cleaned: String = s + .chars() .filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r') .collect(); if cleaned.len() != s.len() { @@ -225,20 +222,23 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value { } } // Attempt 3: repair truncated JSON and retry. - let input = if cleaned.len() == s.len() { s } else { &cleaned }; + let input = if cleaned.len() == s.len() { + s + } else { + &cleaned + }; let repaired = repair_json(input); match serde_json::from_str::(&repaired) { Ok(v) => { - tracing::warn!( - "tool argument string was truncated — repaired successfully", - ); + tracing::warn!("tool argument string was truncated — repaired successfully",); v } Err(e2) => { tracing::error!( "tool argument is a JSON string but failed to parse. \ Wrapping in object. Error: {}. Raw (first 200): {}", - e2, s.chars().take(200).collect::(), + e2, + s.chars().take(200).collect::(), ); serde_json::json!({"_raw": s, "_parse_error": e2.to_string()}) } diff --git a/src/dto/mod.rs b/src/dto/mod.rs index bc66c1b..600a4be 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -1,5 +1,4 @@ //! Data transfer objects shared across the app: chat messages/tool calls //! and provider request/response/usage shapes. - pub mod chat; pub mod provider; diff --git a/src/dto/provider/mod.rs b/src/dto/provider/mod.rs index 50f611c..92eace6 100644 --- a/src/dto/provider/mod.rs +++ b/src/dto/provider/mod.rs @@ -1,5 +1,4 @@ //! Provider-facing DTOs: chat completion request, response, and usage/cost. - pub mod request; pub mod response; pub mod usage; diff --git a/src/dto/provider/request.rs b/src/dto/provider/request.rs index d9c98a0..ac86802 100644 --- a/src/dto/provider/request.rs +++ b/src/dto/provider/request.rs @@ -8,7 +8,6 @@ //! for reserved words like `type`) so no manual (de)serialization glue is //! needed; optional fields use `skip_serializing_if` so unset knobs are //! omitted rather than sent as `null`, matching provider expectations. - use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/src/dto/provider/response.rs b/src/dto/provider/response.rs index b467081..3a2c238 100644 --- a/src/dto/provider/response.rs +++ b/src/dto/provider/response.rs @@ -6,7 +6,6 @@ //! //! Why: separate from the streaming SSE path (see `app/runtime/stream/mod.rs`), //! which parses incremental deltas rather than a single complete payload. - use serde::{Deserialize, Serialize}; /// Non-streaming chat completion response returned by the provider. diff --git a/src/dto/provider/usage.rs b/src/dto/provider/usage.rs index 38b6584..1da05b4 100644 --- a/src/dto/provider/usage.rs +++ b/src/dto/provider/usage.rs @@ -4,7 +4,6 @@ //! chunk when `stream_options.include_usage` is set, or the `usage` field of //! a non-streaming `ChatResponse`) → surfaced to the TUI for cost/token //! display. - use serde::{Deserialize, Serialize}; /// Token counts and optional cost breakdown for a single completion request. diff --git a/src/ipc/client.rs b/src/ipc/client.rs index ed607b9..66ab507 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -4,9 +4,8 @@ //! Flow: `IpcClient::connect_unix` opens a `Connection` (see `conn.rs`) //! to the daemon's socket path → `send`/`receive` exchange framed JSON //! messages (typically `ClientRequest`/`DaemonFrame` from `protocol.rs`). - -use anyhow::Result; use super::conn::Connection; +use anyhow::Result; /// Client-side handle for the `--attach` process: wraps a `Connection` /// to a daemon's Unix socket. diff --git a/src/ipc/conn.rs b/src/ipc/conn.rs index c7f8118..805fb0d 100644 --- a/src/ipc/conn.rs +++ b/src/ipc/conn.rs @@ -6,10 +6,9 @@ //! writes it as one length-prefixed frame (`frame::write_frame`) → //! `receive` reads one frame and deserializes it back to the caller's //! type, propagating a clean peer-close as `Ok(None)`. - -use std::os::unix::net::UnixStream; -use anyhow::Result; use super::frame; +use anyhow::Result; +use std::os::unix::net::UnixStream; /// A framed Unix-socket connection shared by client and server sides of /// the IPC layer; each `send`/`receive` moves one length-prefixed JSON frame. diff --git a/src/ipc/diff.rs b/src/ipc/diff.rs index 4ecbae1..23a7845 100644 --- a/src/ipc/diff.rs +++ b/src/ipc/diff.rs @@ -6,7 +6,6 @@ //! other mismatch is recorded wholesale → results accumulate into a //! `StateDiff`'s `Vec`, built via `StateDiff::new`/`add_change` //! and reset via `clear`. - use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/src/ipc/frame.rs b/src/ipc/frame.rs index 3b02507..1b3e4d4 100644 --- a/src/ipc/frame.rs +++ b/src/ipc/frame.rs @@ -1,4 +1,9 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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 //! the IPC wire protocol. //! @@ -10,9 +15,8 @@ //! Why: a fixed-size length prefix lets the reader know exactly how many //! bytes to pull before attempting to parse, avoiding partial-JSON reads //! over a stream socket. - -use std::io::{Read, Write}; use anyhow::Result; +use std::io::{Read, Write}; /// Upper bound on a single frame's byte size (64 MiB), enforced on both /// the write and read paths to bound memory use and reject malformed or diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index ce0a6d5..00adf96 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -1,7 +1,6 @@ //! Unix-socket IPC layer used to connect a `--attach` TUI client to a //! `--daemon` process: length-prefixed framing, connection wrapper, //! client/server handles, and the wire protocol types. - pub mod client; pub mod conn; pub mod frame; diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs index 60044b9..64c7d3c 100644 --- a/src/ipc/protocol.rs +++ b/src/ipc/protocol.rs @@ -9,7 +9,6 @@ //! Why: `StatePayload`/`MessageEntry`/`ToastEntry` are deliberately flat, //! serializable projections of daemon-side state so the client can //! redraw its TUI without sharing any in-process state with the daemon. - use serde::{Deserialize, Serialize}; /// Wire-serializable subset of `crossterm::event::KeyCode`, sent from diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 0addbb0..2140088 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -4,10 +4,9 @@ //! path (clearing any stale file left by a crashed prior daemon) → //! `accept` blocks for the next client and wraps it as a `Connection` //! (see `conn.rs`) for framed request/response traffic. - -use std::os::unix::net::UnixListener; -use anyhow::Result; use super::conn::Connection; +use anyhow::Result; +use std::os::unix::net::UnixListener; /// Server-side handle for the `--daemon` process: listens on a Unix /// socket and hands out `Connection`s to accepted clients. diff --git a/src/ipc/snapshot.rs b/src/ipc/snapshot.rs index 8402308..3f0c452 100644 --- a/src/ipc/snapshot.rs +++ b/src/ipc/snapshot.rs @@ -6,7 +6,6 @@ //! callers populate/replace its fields as state changes → //! `serialize_snapshot`/`deserialize_snapshot` move it to/from JSON bytes //! for storage or IPC transport. - use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/src/main.rs b/src/main.rs index 89fabb3..440a0d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -480,28 +480,22 @@ fn run_daemon() -> Result<()> { Ok(()) } -/// Run zesdex as a TUI-only client attached to an existing daemon session. +/// Set up the IPC client connection, terminal, and initial state for attach mode. /// -/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate -/// screen → build a local `AppStateRest` mirror (only used for rendering -/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a -/// terminal event (key/resize) and forward it as a `ClientRequest`, or -/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and -/// apply it via `apply_client_update` → redraw → exit when the daemon -/// closes or the user quits (sending `ClientRequest::Close` first). +/// Flow: resolve socket path → connect → enable raw/alt mode → create state. /// -/// Why: Ctrl+C is intercepted locally to quit the client without going -/// through the daemon, since the daemon has no notion of "this client -/// wants to leave" beyond the explicit `Close` request. -fn run_attach(session_id: &str) -> Result<()> { - use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; - use ipc::protocol::ClientRequest; - +/// Return: (client, terminal, `client_state`) on success. +fn setup_attach_client( + session_id: &str, +) -> Result<( + ipc::client::IpcClient, + Terminal>, + app::state::rest::AppStateRest, +)> { let store = model::store::Store::new(); - let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock")); let addr = socket_path.to_string_lossy().to_string(); - let mut client = ipc::client::IpcClient::connect_unix(&addr)?; + let client = ipc::client::IpcClient::connect_unix(&addr)?; enable_raw_mode()?; let mut stdout = io::stdout(); @@ -522,6 +516,60 @@ fn run_attach(session_id: &str) -> Result<()> { ); client_state.session_id = session_id.to_string(); + Ok((client, terminal, client_state)) +} + +/// Process a single daemon frame from the IPC channel, updating state accordingly. +fn handle_daemon_frame( + client_state: &mut app::state::rest::AppStateRest, + frame: Option, +) { + match frame { + Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => { + apply_client_update(client_state, *payload); + } + Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {} + Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => { + client_state.push_toast( + app::state::types::Toast::new( + app::state::types::ToastKind::Info, + message, + ), + ); + } + Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => { + let _ = write_osc52(&mut io::stdout(), &text); + client_state.push_toast( + app::state::types::Toast::new( + app::state::types::ToastKind::Success, + "Copied to clipboard".to_string(), + ), + ); + } + Some(ipc::protocol::DaemonFrame::Closed) | None => { + client_state.quit = true; + } + } +} + +/// Run zesdex as a TUI-only client attached to an existing daemon session. +/// +/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate +/// screen → build a local `AppStateRest` mirror (only used for rendering +/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a +/// terminal event (key/resize) and forward it as a `ClientRequest`, or +/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and +/// apply it via `apply_client_update` → redraw → exit when the daemon +/// closes or the user quits (sending `ClientRequest::Close` first). +/// +/// Why: Ctrl+C is intercepted locally to quit the client without going +/// through the daemon, since the daemon has no notion of "this client +/// wants to leave" beyond the explicit `Close` request. +fn run_attach(session_id: &str) -> Result<()> { + use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + use ipc::protocol::ClientRequest; + + let (mut client, mut terminal, mut client_state) = setup_attach_client(session_id)?; let _rt = tokio::runtime::Runtime::new()?; loop { @@ -575,32 +623,10 @@ fn run_attach(session_id: &str) -> Result<()> { client.send(&ClientRequest::Tick)?; } - match client.receive::()? { - Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => { - apply_client_update(&mut client_state, *payload); - } - Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {} - Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => { - client_state.push_toast( - app::state::types::Toast::new( - app::state::types::ToastKind::Info, - message, - ), - ); - } - Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => { - let _ = write_osc52(&mut io::stdout(), &text); - client_state.push_toast( - app::state::types::Toast::new( - app::state::types::ToastKind::Success, - "Copied to clipboard".to_string(), - ), - ); - } - Some(ipc::protocol::DaemonFrame::Closed) | None => { - client_state.quit = true; - } - } + handle_daemon_frame( + &mut client_state, + client.receive::()?, + ); terminal.draw(|f| { view::draw(f, &client_state); diff --git a/src/model/agent_def/builtin.rs b/src/model/agent_def/builtin.rs index bb4f5b9..f7ea79a 100644 --- a/src/model/agent_def/builtin.rs +++ b/src/model/agent_def/builtin.rs @@ -1,5 +1,4 @@ //! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner). - use crate::app::subagent::spawn::AgentDefinition; /// Build the fixed list of built-in agent definitions shipped with zesdex. diff --git a/src/model/agent_def/global.rs b/src/model/agent_def/global.rs index 7696cac..c5686b5 100644 --- a/src/model/agent_def/global.rs +++ b/src/model/agent_def/global.rs @@ -1,6 +1,5 @@ //! Load, save, and remove user-defined agent definitions stored globally //! (under the store's `agents/` directory), independent of any session. - use crate::app::subagent::spawn::AgentDefinition; /// Load all globally-registered agent definitions from disk. diff --git a/src/model/agent_def/mod.rs b/src/model/agent_def/mod.rs index d90a09a..0068539 100644 --- a/src/model/agent_def/mod.rs +++ b/src/model/agent_def/mod.rs @@ -1,6 +1,5 @@ //! Agent definition sources: built-in defaults, global (user-wide), and //! per-session overrides. - pub mod builtin; pub mod global; pub mod session; diff --git a/src/model/agent_def/session.rs b/src/model/agent_def/session.rs index 62f03d5..f5c78ba 100644 --- a/src/model/agent_def/session.rs +++ b/src/model/agent_def/session.rs @@ -1,6 +1,5 @@ //! Load, save, add, and remove agent definitions scoped to a single //! session (`/agents.json`). - use std::path::Path; use crate::app::subagent::spawn::AgentDefinition; diff --git a/src/model/app_config.rs b/src/model/app_config.rs index ae0c6b2..448cb70 100644 --- a/src/model/app_config.rs +++ b/src/model/app_config.rs @@ -1,6 +1,5 @@ //! Application-level configuration: LLM providers, model roles, and defaults, //! persisted to `app_config.json` in the store directory. - use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -38,26 +37,35 @@ pub struct ModelRole { impl Default for AppConfig { fn default() -> Self { let mut providers = HashMap::new(); - providers.insert("zen".to_string(), ProviderConfig { - api_base: "https://opencode.ai/zen/v1".to_string(), - api_key_env: Some("API_KEY".to_string()), - default_model: Some("deepseek-v4-flash-free".to_string()), - default_api_key: None, - }); - providers.insert("router".to_string(), ProviderConfig { - api_base: "https://9router.asepharyana.my.id/v1".to_string(), - api_key_env: Some("ROUTER_API_KEY".to_string()), - default_model: Some("claude-opus-4-8".to_string()), - default_api_key: None, - }); + providers.insert( + "zen".to_string(), + ProviderConfig { + api_base: "https://opencode.ai/zen/v1".to_string(), + api_key_env: Some("API_KEY".to_string()), + default_model: Some("deepseek-v4-flash-free".to_string()), + default_api_key: None, + }, + ); + providers.insert( + "router".to_string(), + ProviderConfig { + api_base: "https://9router.asepharyana.my.id/v1".to_string(), + api_key_env: Some("ROUTER_API_KEY".to_string()), + default_model: Some("claude-opus-4-8".to_string()), + default_api_key: None, + }, + ); let mut model_roles = HashMap::new(); - model_roles.insert("default".to_string(), ModelRole { - provider: "zen".to_string(), - model: "deepseek-v4-flash-free".to_string(), - max_tokens: None, - context_window: None, - temperature: Some(0.7), - }); + model_roles.insert( + "default".to_string(), + ModelRole { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + max_tokens: None, + context_window: None, + temperature: Some(0.7), + }, + ); AppConfig { providers, model_roles, @@ -89,7 +97,8 @@ impl AppConfig { Err(e) => { tracing::warn!( "warning: failed to parse config file '{}': {}. Loading defaults.", - path.display(), e + path.display(), + e ); Self::default() } @@ -103,7 +112,9 @@ impl AppConfig { } // Auto-detect provider from ~/.claude/settings.json if let Some(claude_provider) = detect_claude_settings_provider() { - cfg.providers.entry("claude".to_string()).or_insert(claude_provider); + cfg.providers + .entry("claude".to_string()) + .or_insert(claude_provider); // Register known Claude models as named model roles let claude_models = [ ("claude-opus-4-8", "claude-opus-4-8"), @@ -111,13 +122,15 @@ impl AppConfig { ("claude-haiku-4-5", "claude-haiku-4-5-20251001"), ]; for (role_name, model_name) in &claude_models { - cfg.model_roles.entry(role_name.to_string()).or_insert(ModelRole { - provider: "claude".to_string(), - model: model_name.to_string(), - max_tokens: Some(8192), - context_window: Some(200_000), - temperature: Some(0.7), - }); + cfg.model_roles + .entry(role_name.to_string()) + .or_insert(ModelRole { + provider: "claude".to_string(), + model: model_name.to_string(), + max_tokens: Some(8192), + context_window: Some(200_000), + temperature: Some(0.7), + }); } // Set as default provider only if user hasn't picked a custom default if cfg.default_provider == defaults.default_provider { @@ -154,8 +167,7 @@ struct ClaudeSettings { /// than through its settings file, so reading only the file misses them. fn detect_claude_settings_provider() -> Option { // Prefer the file, then fall back to env vars. - let (base_url, key) = claude_credentials_from_file() - .or_else(claude_credentials_from_env)?; + let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?; Some(ProviderConfig { api_base: base_url, // Keep the env-var name so runtime env overrides still work. diff --git a/src/model/conversation.rs b/src/model/conversation.rs index de32de8..801a7ff 100644 --- a/src/model/conversation.rs +++ b/src/model/conversation.rs @@ -1,6 +1,5 @@ //! In-memory conversation state: message history plus the system prompt and //! model parameters used to drive the LLM. - use serde::{Deserialize, Serialize}; /// A single conversation's message history and generation settings. diff --git a/src/model/editlog.rs b/src/model/editlog.rs index a74406a..dd9201f 100644 --- a/src/model/editlog.rs +++ b/src/model/editlog.rs @@ -1,6 +1,5 @@ //! Append-only JSONL edit log recording every file mutation made by tools, //! for audit and undo/history purposes. - use serde::{Deserialize, Serialize}; /// A single recorded file edit: which tool made it, to which path, why, @@ -44,7 +43,9 @@ impl EditLog { /// regardless of the in-memory limit. fn load_from_disk(path: &std::path::Path) -> Vec { use std::io::{BufRead, BufReader}; - let Ok(file) = std::fs::File::open(path) else { return Vec::new() }; + let Ok(file) = std::fs::File::open(path) else { + return Vec::new(); + }; let reader = BufReader::new(file); let mut entries: Vec = Vec::new(); for line in reader.lines() { @@ -151,7 +152,8 @@ mod tests { bytes_delta: 10 + i, origin: "main".to_string(), session_id: "sess-1".to_string(), - }).unwrap(); + }) + .unwrap(); } assert_eq!(log.len(), 5); assert_eq!(log.entries[0].reason, "reason 0"); diff --git a/src/model/memory.rs b/src/model/memory.rs index 692d12c..1eb7ae0 100644 --- a/src/model/memory.rs +++ b/src/model/memory.rs @@ -1,8 +1,7 @@ //! Long-term agent memory: markdown files with YAML-ish frontmatter storing //! lessons/references, plus slugified filenames and export/import helpers. - -use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; /// A single memory entry (lesson, reference, etc.) with frontmatter /// metadata and free-form markdown content. @@ -72,15 +71,30 @@ impl Memory { /// /// Return: `Ok(())` on success, or an `io::Error` from directory /// creation, the temp write, or the rename. - #[allow(clippy::suspicious_open_options)] pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> { let path = Self::path(memory_dir, &self.name); let parent = path.parent().unwrap(); std::fs::create_dir_all(parent)?; - 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 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 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 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 prov_line = if self.provenances.is_empty() { String::new() } else { @@ -99,6 +113,7 @@ impl Memory { use std::io::Write; let mut f = std::fs::OpenOptions::new() .create(true) + .truncate(true) .write(true) .open(&tmp)?; f.write_all(content.as_bytes())?; @@ -139,7 +154,10 @@ impl Memory { let content = content.strip_prefix("---\n").unwrap_or(content); let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); if parts.len() < 2 { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter")); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "missing frontmatter", + )); } let front: std::collections::HashMap = parts[0] .lines() @@ -153,16 +171,34 @@ impl Memory { name: front.get("name").cloned().unwrap_or_default(), description: front.get("description").cloned().unwrap_or_default(), content: body, - kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()), - created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0), - updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0), + kind: front + .get("kind") + .cloned() + .unwrap_or_else(|| "reference".to_string()), + created_at: front + .get("created_at") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + updated_at: front + .get("updated_at") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), - lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()), + lifecycle: front + .get("lifecycle") + .cloned() + .unwrap_or_else(|| "new".to_string()), scope: front.get("scope").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()), - provenances: front.get("provenances").cloned() - .map(|s| s.split(", ").map(std::string::ToString::to_string).collect()) + provenances: front + .get("provenances") + .cloned() + .map(|s| { + s.split(", ") + .map(std::string::ToString::to_string) + .collect() + }) .unwrap_or_default(), }) } @@ -186,13 +222,17 @@ impl Memory { /// Return: slugs (without extension); empty `Vec` if the directory /// can't be read. pub fn list(memory_dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(memory_dir) else { return Vec::new() }; + let Ok(entries) = std::fs::read_dir(memory_dir) else { + return Vec::new(); + }; entries .filter_map(std::result::Result::ok) .filter(|e| e.path().extension().is_some_and(|x| x == "md")) .filter_map(|e| { let name = e.file_name().to_string_lossy().to_string(); - if name == "MEMORY.md" { return None; } + if name == "MEMORY.md" { + return None; + } let slug = name.strip_suffix(".md")?.to_string(); Some(slug) }) @@ -209,11 +249,22 @@ impl Memory { /// Why: leading-dot stripping specifically blocks accidental hidden /// files and `..`-style traversal attempts embedded in `raw`. pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { - let clean: String = raw.chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' }) + let clean: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' { + c + } else { + '-' + } + }) .collect(); let clean = clean.trim_start_matches('.').to_string(); - memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean }) + memory_dir.join(if clean.is_empty() { + "memory.md" + } else { + &clean + }) } /// Export all memories in `memory_dir` to a single JSON file. @@ -227,11 +278,11 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { #[cfg(test)] pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> { let names = Memory::list(memory_dir); - let lessons: Vec = names.iter() + let lessons: Vec = names + .iter() .filter_map(|n| Memory::read(memory_dir, n).ok()) .collect(); - let data = serde_json::to_string_pretty(&lessons) - .map_err(std::io::Error::other)?; + let data = serde_json::to_string_pretty(&lessons).map_err(std::io::Error::other)?; // Write to temp, fsync, then rename for crash-safe export let tmp = output.with_extension("json.tmp"); std::fs::write(&tmp, data)?; @@ -259,7 +310,8 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result let data = std::fs::read_to_string(input)?; let lessons: Vec = serde_json::from_str(&data) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - let existing: std::collections::HashSet = Memory::list(memory_dir).into_iter().collect(); + let existing: std::collections::HashSet = + Memory::list(memory_dir).into_iter().collect(); let mut imported = 0; for lesson in &lessons { let slug = Memory::slugify(&lesson.name).unwrap_or_default(); @@ -282,12 +334,18 @@ mod tests { #[test] fn test_slugify_basic() { - assert_eq!(Memory::slugify("Hello World"), Some("hello-world".to_string())); + assert_eq!( + Memory::slugify("Hello World"), + Some("hello-world".to_string()) + ); } #[test] fn test_slugify_special_chars() { - assert_eq!(Memory::slugify("Use & Avoid! @#$"), Some("use-avoid".to_string())); + assert_eq!( + Memory::slugify("Use & Avoid! @#$"), + Some("use-avoid".to_string()) + ); } #[test] @@ -375,7 +433,10 @@ mod tests { }; mem.write(&dir).unwrap(); 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); } diff --git a/src/model/mod.rs b/src/model/mod.rs index e156da5..0e7f938 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,6 +1,5 @@ //! Persistence and domain model layer: sessions, conversations, memory, //! message log (`SQLite`), edit log, and app/settings config. - pub mod app_config; pub mod editlog; pub mod memory; diff --git a/src/model/msglog/blobs.rs b/src/model/msglog/blobs.rs index 32aae59..c0e6c3a 100644 --- a/src/model/msglog/blobs.rs +++ b/src/model/msglog/blobs.rs @@ -1,8 +1,7 @@ //! Binary blob storage in the message-log `SQLite` database (e.g. images, //! attachments), keyed by session id and an arbitrary blob key. - -use rusqlite::{Connection, params}; use anyhow::Result; +use rusqlite::{params, Connection}; /// Insert or overwrite a blob for a session under `blob_key`. /// @@ -10,7 +9,13 @@ use anyhow::Result; /// keyed on `(session_id, blob_key)`. /// /// 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(); conn.execute( "INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -23,7 +28,11 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u /// /// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row /// exists, `Err` for any other `SQLite` failure. -pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result>> { +pub fn retrieve_blob( + conn: &Connection, + session_id: &str, + blob_key: &str, +) -> Result>> { let result = conn.query_row( "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", params![session_id, blob_key], @@ -36,30 +45,14 @@ pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Res } } -/// Delete a blob for a session by key. -/// -/// Return: `Ok(true)` if a row was deleted, `Ok(false)` if no matching -/// row existed. -#[allow(dead_code)] -pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result { - let rows = conn.execute( - "DELETE FROM blobs WHERE session_id = ?1 AND blob_key = ?2", - params![session_id, blob_key], - )?; - Ok(rows > 0) -} - /// List all blob keys stored for a session, oldest first. /// /// Return: `Ok(Vec)` of keys ordered by `created_at`, or the /// underlying `SQLite` error. pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC" - )?; - let rows = stmt.query_map(params![session_id], |row| { - row.get::<_, String>(0) - })?; + let mut stmt = + conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?; + let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?; let mut keys = Vec::new(); for row in rows { keys.push(row?); diff --git a/src/model/msglog/mod.rs b/src/model/msglog/mod.rs index 712df06..b4e889e 100644 --- a/src/model/msglog/mod.rs +++ b/src/model/msglog/mod.rs @@ -1,6 +1,5 @@ //! SQLite-backed message log: per-session `messages.sqlite` storing chat //! messages, blobs, and archive/summary metadata. - pub mod blobs; pub mod query; pub mod schema; diff --git a/src/model/msglog/query.rs b/src/model/msglog/query.rs index ece9d0e..9b1669c 100644 --- a/src/model/msglog/query.rs +++ b/src/model/msglog/query.rs @@ -1,8 +1,7 @@ //! Insert queries against the message log's `messages` table. - -use rusqlite::{Connection, params}; -use anyhow::Result; use crate::dto::chat::message::{ChatMessage, Role}; +use anyhow::Result; +use rusqlite::{params, Connection}; /// Insert a chat message into the session's message log. /// @@ -15,9 +14,10 @@ pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> let content = msg.content.as_deref(); let tool_call_id = msg.tool_call_id.as_deref(); let tool_name = msg.name.as_deref(); - let tool_arguments = msg.tool_calls.as_ref().map(|calls| { - serde_json::to_string(calls).unwrap_or_default() - }); + let tool_arguments = msg + .tool_calls + .as_ref() + .map(|calls| serde_json::to_string(calls).unwrap_or_default()); let created_at = chrono::Utc::now().timestamp_millis(); let role_str = match msg.role { Role::User => "user", diff --git a/src/model/msglog/schema.rs b/src/model/msglog/schema.rs index 45f2829..075855a 100644 --- a/src/model/msglog/schema.rs +++ b/src/model/msglog/schema.rs @@ -1,7 +1,6 @@ //! `SQLite` schema definition for the message log database. - -use rusqlite::Connection; use anyhow::Result; +use rusqlite::Connection; /// Create the message log's tables and indexes if they don't already /// exist (`messages`, `archives`, `blobs`). @@ -51,7 +50,7 @@ pub fn init_schema(conn: &Connection) -> Result<()> { created_at INTEGER NOT NULL, UNIQUE(session_id, blob_key) ); - " + ", )?; Ok(()) } diff --git a/src/model/msglog/summary.rs b/src/model/msglog/summary.rs index bf68e84..119323b 100644 --- a/src/model/msglog/summary.rs +++ b/src/model/msglog/summary.rs @@ -1,6 +1,5 @@ //! Session archive/summary metadata tracked alongside the message log //! (title, model, counts, and a rolling text summary). - use serde::{Deserialize, Serialize}; /// Summary metadata for one archived/summarized session. diff --git a/src/model/session.rs b/src/model/session.rs index edbf489..67f484e 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -1,9 +1,8 @@ //! Session metadata: id, title, workspace roots, and message/token counts, //! persisted as `session.json` per session directory. - -use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; /// Metadata for one conversation session (distinct from the message /// history itself, which lives in `Conversation`/the msglog). @@ -108,7 +107,9 @@ impl Session { /// contains no valid sessions. pub fn list(base_dir: &Path) -> Vec { let sessions_dir = base_dir.join("sessions"); - let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Vec::new() }; + let Ok(entries) = std::fs::read_dir(&sessions_dir) else { + return Vec::new(); + }; entries .filter_map(std::result::Result::ok) .filter(|e| e.path().is_dir()) diff --git a/src/model/session_lock.rs b/src/model/session_lock.rs index 71f01c7..87173c1 100644 --- a/src/model/session_lock.rs +++ b/src/model/session_lock.rs @@ -1,10 +1,14 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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 //! the same session directory concurrently. - -use std::path::{Path, PathBuf}; use std::fs; use std::io::Write; +use std::path::{Path, PathBuf}; /// A PID-file lock (`/.lock`) tied to the current process, /// auto-removed on drop. @@ -38,7 +42,6 @@ impl SessionLock { /// /// Return: `Ok(true)` if acquired, `Ok(false)` if another live /// process holds it, `Err` on I/O failure. - #[allow(clippy::suspicious_open_options)] pub fn try_lock(&self) -> std::io::Result { // Phase 1: try atomic create. If it succeeds, the lock is ours. match fs::OpenOptions::new() @@ -60,7 +63,7 @@ impl SessionLock { // Phase 2: lock file exists — check liveness of the owning process. let content = fs::read_to_string(&self.path).unwrap_or_default(); if let Ok(pid) = content.trim().parse::() { - if self.is_alive(pid) { + if Self::is_alive(pid) { return Ok(false); } } @@ -71,6 +74,7 @@ impl SessionLock { { let mut tmp_file = fs::OpenOptions::new() .create(true) + .truncate(true) .write(true) .open(&tmp)?; write!(tmp_file, "{}", self.pid)?; @@ -92,8 +96,7 @@ impl SessionLock { /// Check whether a process with the given PID is currently alive and /// is actually a zesdex process (not a recycled PID from a different /// program). - #[allow(clippy::unused_self)] - fn is_alive(&self, pid: u32) -> bool { + fn is_alive(pid: u32) -> bool { // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // whether the process exists and the caller has permission to signal // it. The integer argument is a PID already validated by `try_lock`. @@ -106,11 +109,15 @@ impl SessionLock { // our lock). This is best-effort — /proc may not be available // on all platforms. let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); - if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() { - if target != exe { - return false; + if let Ok(target) = std::fs::read_link(&proc_exe) { + if let Ok(exe) = std::env::current_exe() { + if target != exe { + return false; + } + } else { /* cannot resolve own exe, trust kill check */ } - } else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ } + } else { /* /proc unavailable, trust kill check */ + } true } } diff --git a/src/model/settings.rs b/src/model/settings.rs index aa6b960..3b13f52 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -24,6 +24,24 @@ fn default_hive_mind_node_timeout_ms() -> u64 { 600_000 } +/// Boolean flags grouped to keep the top-level struct below clippy's bool threshold. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsFlags { + pub review_enabled: bool, + pub session_archive_enabled: bool, + pub lsp_auto_provision: bool, +} + +impl Default for SettingsFlags { + fn default() -> Self { + Self { + review_enabled: true, + session_archive_enabled: true, + lsp_auto_provision: true, + } + } +} + /// Top-level application settings, serialized to `settings.json` in the store dir. /// /// Why: a single flat struct rather than nested config so the JSON file stays @@ -36,28 +54,21 @@ pub struct Settings { pub api_keys: std::collections::HashMap, pub max_tokens: Option, pub temperature: Option, - pub review_enabled: bool, pub review_max_lessons_per_run: usize, pub adaptive_review_max_skip: u32, pub verify_command: Option, pub verify_timeout_ms: u64, pub workflow_max_concurrency: usize, - pub session_archive_enabled: bool, - pub lsp_auto_provision: bool, + /// Boolean flags flattened into the top-level JSON so existing settings + /// files remain compatible when bools are grouped into a sub-struct. + #[serde(flatten)] + pub flags: SettingsFlags, pub lsp_languages: Vec, /// Wall-clock deadline for a single hive-mind processing node (cycle /// node or synthesis node). Prevents one stuck node from hanging an /// entire hive-mind convergence forever. #[serde(default = "default_hive_mind_node_timeout_ms")] pub hive_mind_node_timeout_ms: u64, - /// Off by default. When enabled, appends an instruction to the - /// system prompt asking the model to write tersely — drop articles, - /// filler words, hedging, and pleasantries; keep code, commands, and - /// error text byte-exact — with an explicit exception for - /// destructive-operation confirmations and security warnings, which - /// always get full detail regardless of this setting. - #[serde(default)] - pub concise_output: bool, } impl Default for Settings { @@ -69,17 +80,14 @@ impl Default for Settings { api_keys: std::collections::HashMap::new(), max_tokens: None, temperature: None, - review_enabled: true, review_max_lessons_per_run: 5, adaptive_review_max_skip: 3, verify_command: None, verify_timeout_ms: 30000, workflow_max_concurrency: 5, - session_archive_enabled: true, - lsp_auto_provision: true, + flags: SettingsFlags::default(), lsp_languages: Vec::new(), hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(), - concise_output: false, } } } @@ -132,38 +140,6 @@ mod tests { assert_eq!(settings.hive_mind_node_timeout_ms, 600_000); } - #[test] - fn concise_output_defaults_to_false() { - assert!(!Settings::default().concise_output); - } - - #[test] - fn missing_concise_output_field_falls_back_to_default() { - // Simulates loading a settings.json written before this field - // existed — #[serde(default)] must fill it in rather than - // failing the whole parse. - let old_json = r#"{ - "internet_mode": "Off", - "provider": "zen", - "model": "deepseek-v4-flash-free", - "api_keys": {}, - "max_tokens": null, - "temperature": null, - "review_enabled": true, - "review_max_lessons_per_run": 5, - "adaptive_review_max_skip": 3, - "verify_command": null, - "verify_timeout_ms": 30000, - "workflow_max_concurrency": 5, - "session_archive_enabled": true, - "lsp_auto_provision": true, - "lsp_languages": [] - }"#; - let parsed: Settings = serde_json::from_str(old_json) - .expect("must parse even without the new field present"); - assert!(!parsed.concise_output); - } - #[test] fn missing_hive_mind_node_timeout_field_falls_back_to_default() { // Simulates loading a settings.json written before this field @@ -178,13 +154,13 @@ mod tests { "max_tokens": null, "temperature": null, "review_enabled": true, + "session_archive_enabled": true, + "lsp_auto_provision": true, "review_max_lessons_per_run": 5, "adaptive_review_max_skip": 3, "verify_command": null, "verify_timeout_ms": 30000, "workflow_max_concurrency": 5, - "session_archive_enabled": true, - "lsp_auto_provision": true, "lsp_languages": [] }"#; let parsed: Settings = serde_json::from_str(old_json) diff --git a/src/model/store.rs b/src/model/store.rs index 686ddc6..3dbc645 100644 --- a/src/model/store.rs +++ b/src/model/store.rs @@ -1,7 +1,6 @@ //! Filesystem layout for zesdex's persistent and scratch data directories. - -use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; /// Resolved paths for all data directories zesdex reads from and writes to. /// diff --git a/src/resources.rs b/src/resources.rs index dfb8473..9c2cdbb 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,6 +1,5 @@ //! Compile-time embedded text resources: the system prompt, tool descriptions, //! and the in-app help screen shown on Ctrl+H. - pub const SYSTEM_PROMPT: &str = include_str!("../src-misc/system-prompt.txt"); pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt"); diff --git a/src/service/mod.rs b/src/service/mod.rs index 87712b6..c7c4918 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,4 +1,3 @@ //! External service integrations: the LLM provider HTTP client and OAuth flows. - -pub mod provider; pub mod oauth; +pub mod provider; diff --git a/src/service/oauth/loopback.rs b/src/service/oauth/loopback.rs index f1f9e7b..b1c6a97 100644 --- a/src/service/oauth/loopback.rs +++ b/src/service/oauth/loopback.rs @@ -1,6 +1,10 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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. - use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -60,9 +64,17 @@ impl LoopbackServer { let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); if !state_ok { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "state mismatch")); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "state mismatch", + )); } - code.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")) + code.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "code not found in callback", + ) + }) } /// Extract and percent-decode the `code` query parameter from an HTTP request line. @@ -108,10 +120,14 @@ fn urlencoding(s: &str) -> String { let mut chars = s.chars(); while let Some(c) = chars.next() { if c == '%' { - match (chars.next().and_then(|c| c.to_digit(16)), - chars.next().and_then(|c| c.to_digit(16))) { + match ( + chars.next().and_then(|c| c.to_digit(16)), + chars.next().and_then(|c| c.to_digit(16)), + ) { (Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)), - _ => { result.push('%'); } + _ => { + result.push('%'); + } } } else { result.push(c); diff --git a/src/service/oauth/manager.rs b/src/service/oauth/manager.rs index 14ad355..0a0840a 100644 --- a/src/service/oauth/manager.rs +++ b/src/service/oauth/manager.rs @@ -1,7 +1,6 @@ //! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building. - -use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; /// An OAuth access token plus its refresh token and absolute expiry (unix seconds). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -12,8 +11,7 @@ pub struct OAuthToken { pub token_type: String, } -impl OAuthToken { -} +impl OAuthToken {} /// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -32,7 +30,11 @@ impl Default for OAuthConfig { token_url: String::new(), client_id: String::new(), client_secret: None, - scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], + scopes: vec![ + "openid".to_string(), + "profile".to_string(), + "email".to_string(), + ], } } } @@ -60,7 +62,12 @@ impl OAuthManager { /// compute absolute `expires_at` from `expires_in` → store on `self.token`. /// /// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field. - pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> { + pub fn exchange_code( + &mut self, + code: &str, + redirect_uri: &str, + code_verifier: &str, + ) -> Result<(), String> { let mut params = std::collections::HashMap::new(); params.insert("grant_type", "authorization_code"); params.insert("code", code); @@ -68,7 +75,8 @@ impl OAuthManager { params.insert("client_id", &self.config.client_id); params.insert("code_verifier", code_verifier); - let resp = self.client + let resp = self + .client .post(&self.config.token_url) .form(¶ms) .send() @@ -81,13 +89,21 @@ impl OAuthManager { 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(); let expires_in = body["expires_in"].as_u64().unwrap_or(3600); - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); self.token = Some(OAuthToken { access_token, - refresh_token: body["refresh_token"].as_str().map(std::string::ToString::to_string), + refresh_token: body["refresh_token"] + .as_str() + .map(std::string::ToString::to_string), expires_at: now + expires_in, token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), }); diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index c00f4a2..b70f74f 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -1,6 +1,5 @@ //! OAuth 2.0 authorization-code + PKCE support: verifier/challenge generation, //! the loopback redirect server, and the token-exchange manager. - -pub mod pkce; pub mod loopback; pub mod manager; +pub mod pkce; diff --git a/src/service/oauth/pkce.rs b/src/service/oauth/pkce.rs index b3df023..0eb425f 100644 --- a/src/service/oauth/pkce.rs +++ b/src/service/oauth/pkce.rs @@ -1,8 +1,12 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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. - -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use sha2::{Sha256, Digest}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use sha2::{Digest, Sha256}; const VERIFIER_LENGTH: usize = 64; diff --git a/src/service/provider.rs b/src/service/provider.rs index 905893b..67e6c03 100644 --- a/src/service/provider.rs +++ b/src/service/provider.rs @@ -1,11 +1,10 @@ //! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs, //! supporting both non-streaming and SSE-streaming requests with automatic retry. - -use std::time::Duration; use anyhow::Result; +use std::time::Duration; -use crate::app::runtime::stream::{SseParser, StreamEvent}; use crate::app::runtime::stream::turn::StreamedTurn; +use crate::app::runtime::stream::{SseParser, StreamEvent}; use crate::dto::chat::message::ChatMessage; use crate::dto::provider::request::{ChatRequest, StreamOptions, ToolDef}; @@ -76,7 +75,9 @@ impl LlmClient { LlmClient { client, api_key, - base_url: base_url.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), + base_url: base_url + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), model, } } @@ -115,7 +116,8 @@ impl LlmClient { loop { attempt += 1; - let mut http_req = self.client + let mut http_req = self + .client .post(&url) .header("Content-Type", "application/json"); @@ -142,7 +144,10 @@ impl LlmClient { let data: crate::dto::provider::response::ChatResponse = resp.json()?; let usage = data.usage.map(|u| { - (u64::from(u.prompt_tokens.unwrap_or(0)), u64::from(u.completion_tokens.unwrap_or(0))) + ( + u64::from(u.prompt_tokens.unwrap_or(0)), + u64::from(u.completion_tokens.unwrap_or(0)), + ) }); let message = data .choices @@ -158,7 +163,8 @@ impl LlmClient { Err(e) => { let err_str = e.to_string(); let err_lower = err_str.to_lowercase(); - let is_auth_error = err_str.contains("401") || err_str.contains("403") + let is_auth_error = err_str.contains("401") + || err_str.contains("403") || err_lower.contains("unauthorized") || err_lower.contains("forbidden") || err_lower.contains("authentication failed"); @@ -195,7 +201,9 @@ impl LlmClient { stream: Some(true), top_p: None, stop: None, - stream_options: Some(StreamOptions { include_usage: true }), + stream_options: Some(StreamOptions { + include_usage: true, + }), }; let url = format!("{}/chat/completions", self.base_url); @@ -217,7 +225,8 @@ impl LlmClient { Err(e) => { let err_str = e.to_string(); let err_lower = err_str.to_lowercase(); - let is_auth_error = err_str.contains("401") || err_str.contains("403") + let is_auth_error = err_str.contains("401") + || err_str.contains("403") || err_lower.contains("unauthorized") || err_lower.contains("forbidden") || err_lower.contains("authentication failed"); @@ -250,7 +259,8 @@ impl LlmClient { ) -> Result<(ChatMessage, Option<(u64, u64)>)> { use std::io::Read; - let mut http_req = self.client + let mut http_req = self + .client .post(url) .header("Content-Type", "application/json"); if !self.api_key.is_empty() { @@ -281,7 +291,8 @@ impl LlmClient { let mut chunk_buf = [0u8; 4096]; loop { - let n = reader.read(&mut chunk_buf) + let n = reader + .read(&mut chunk_buf) .map_err(|e| anyhow::anyhow!("stream read error: {e}"))?; if n == 0 { break; @@ -302,7 +313,11 @@ impl LlmClient { anyhow::bail!("aborted"); } match &event { - StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { + StreamEvent::Usage { + prompt_tokens, + completion_tokens, + .. + } => { usage = Some((*prompt_tokens, *completion_tokens)); } StreamEvent::Error(msg) => { @@ -326,9 +341,7 @@ impl LlmClient { // into a tool call that will misbehave (e.g. a `write` call with a // half-written file body). if let Some((name, err)) = turn.incomplete_tool_call() { - anyhow::bail!( - "stream ended before tool call '{name}' arguments were complete: {err}" - ); + anyhow::bail!("stream ended before tool call '{name}' arguments were complete: {err}"); } turn.is_complete = true; diff --git a/src/tool/bash_tools.rs b/src/tool/bash_tools.rs index 27f6886..f17cf88 100644 --- a/src/tool/bash_tools.rs +++ b/src/tool/bash_tools.rs @@ -1,10 +1,9 @@ //! Tool implementations for interacting with background bash jobs: `bash_output` //! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool: fetch buffered output from a background bash job by `job_id`. pub struct BashOutput; @@ -32,7 +31,8 @@ impl Tool for BashOutput { } fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let job_id = args.get("job_id") + let job_id = args + .get("job_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: job_id"))? .to_string(); @@ -74,7 +74,8 @@ impl Tool for BashKill { } fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let job_id = args.get("job_id") + let job_id = args + .get("job_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: job_id"))? .to_string(); @@ -93,7 +94,9 @@ fn is_valid_job_id(id: &str) -> bool { if parts.len() != 5 { return false; } - parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_hexdigit())) + parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_hexdigit())) && parts[0].len() == 8 && parts[1].len() == 4 && parts[2].len() == 4 diff --git a/src/tool/fs/delete.rs b/src/tool/fs/delete.rs index a8e76c3..223fd07 100644 --- a/src/tool/fs/delete.rs +++ b/src/tool/fs/delete.rs @@ -1,13 +1,12 @@ //! Tool: `delete` — remove a file or empty directory relative to a workspace root. - -use std::fs; -use std::path::PathBuf; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; +use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::super::resolve_path; use super::helpers::arg_str; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; /// Tool: delete a file or empty directory. Refuses non-empty directories. pub struct Delete; @@ -47,10 +46,15 @@ impl Tool for Delete { let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { - return Ok(format!("path '{}' does not exist (resolved to {})", rel, path.display())); + return Ok(format!( + "path '{}' does not exist (resolved to {})", + rel, + path.display() + )); } - let metadata = path.metadata() + let metadata = path + .metadata() .map_err(|e| anyhow!("failed to read metadata for '{rel}': {e}"))?; if metadata.is_dir() { @@ -66,8 +70,7 @@ impl Tool for Delete { anyhow::bail!("directory '{rel}' is not empty (refusing to delete)"); } } else { - fs::remove_file(&path) - .map_err(|e| anyhow!("failed to delete '{rel}': {e}"))?; + fs::remove_file(&path).map_err(|e| anyhow!("failed to delete '{rel}': {e}"))?; Ok(format!("deleted {rel}")) } } diff --git a/src/tool/fs/edit.rs b/src/tool/fs/edit.rs index 67001ae..c2d19ec 100644 --- a/src/tool/fs/edit.rs +++ b/src/tool/fs/edit.rs @@ -1,16 +1,20 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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. - -use std::fs; -use std::path::PathBuf; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; +use super::super::check_graduated_checks; +use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::super::resolve_path; -use super::super::check_graduated_checks; use super::helpers::{self, arg_str}; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; use similar::TextDiff; +use std::fs; +use std::path::PathBuf; /// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true. pub struct Edit; @@ -69,19 +73,28 @@ impl Tool for Edit { anyhow::bail!("reason must be a non-empty string"); } if old.is_empty() { - 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 replace_all = args.get("replace_all").and_then(serde_json::Value::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)?; 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() { anyhow::bail!("'{rel}' is a directory, not a file"); } - let content = fs::read_to_string(&path) - .map_err(|e| anyhow!("failed to read '{rel}': {e}"))?; + let content = + fs::read_to_string(&path).map_err(|e| anyhow!("failed to read '{rel}': {e}"))?; if !content.contains(&old) { anyhow::bail!("old string not found in '{rel}'"); } @@ -98,12 +111,14 @@ impl Tool for Edit { } else { content.replacen(&old, &new_str, 1) }; - fs::write(&path, &new_content) - .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; + fs::write(&path, &new_content).map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; let text_diff = TextDiff::from_lines(content.as_str(), new_content.as_str()); let diff_text = format!( "{}", - text_diff.unified_diff().context_radius(3).header(&rel, &rel) + text_diff + .unified_diff() + .context_radius(3) + .header(&rel, &rel) ); let diff_block = format!("```diff\n{}\n```", helpers::truncate_diff(&diff_text)); // Notify the LSP server of the on-disk change so diagnostics stay fresh. @@ -118,7 +133,10 @@ impl Tool for Edit { if check_matches.is_empty() { Ok(format!("edited {rel}\n{diff_block}{lsp_note}")) } else { - Ok(format!("edited {rel}. Graduated checks matched: {}\n{diff_block}{lsp_note}", check_matches.join(", "))) + Ok(format!( + "edited {rel}. Graduated checks matched: {}\n{diff_block}{lsp_note}", + check_matches.join(", ") + )) } } } @@ -128,7 +146,9 @@ mod tests { use super::*; fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx { - crate::tool::ToolCtx::builder().workspaces(vec![workspace]).build() + crate::tool::ToolCtx::builder() + .workspaces(vec![workspace]) + .build() } fn temp_workspace() -> std::path::PathBuf { @@ -158,8 +178,16 @@ mod tests { #[test] fn edit_truncates_a_very_large_diff() { let workspace = temp_workspace(); - let old_content: String = (0..300).map(|i| format!("line{i}\n")).collect(); - let new_content: String = (0..300).map(|i| format!("changed{i}\n")).collect(); + let old_content: String = (0..300).fold(String::new(), |mut acc, i| { + use std::fmt::Write; + let _ = writeln!(acc, "line{i}"); + acc + }); + let new_content: String = (0..300).fold(String::new(), |mut acc, i| { + use std::fmt::Write; + let _ = writeln!(acc, "changed{i}"); + acc + }); fs::write(workspace.join("big.txt"), &old_content).unwrap(); let ctx = test_ctx(workspace.clone()); let args = json!({ diff --git a/src/tool/fs/helpers.rs b/src/tool/fs/helpers.rs index 9df5d97..a4fe968 100644 --- a/src/tool/fs/helpers.rs +++ b/src/tool/fs/helpers.rs @@ -1,9 +1,8 @@ //! Shared helpers for filesystem tools: extracting string arguments from JSON //! and producing user-friendly "not found" diagnostics. - -use std::path::Path; +use anyhow::{anyhow, Result}; use serde_json::Value; -use anyhow::{Result, anyhow}; +use std::path::Path; /// Extract a required string argument from a JSON args map. /// @@ -29,12 +28,20 @@ pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> St canon.starts_with(&wc) }); if in_ws { - format!("path '{}' does not exist (resolved to {})", rel, canon.display()) + format!( + "path '{}' does not exist (resolved to {})", + rel, + canon.display() + ) } else { format!( "path '{}' is outside all workspace roots. Workspace roots: {}", rel, - ctx.workspaces.iter().map(|w| w.display().to_string()).collect::>().join(", ") + ctx.workspaces + .iter() + .map(|w| w.display().to_string()) + .collect::>() + .join(", ") ) } } @@ -52,7 +59,10 @@ pub fn truncate_diff(diff: &str) -> String { return diff.to_string(); } let remaining = lines.len() - MAX_DIFF_LINES; - format!("{}\n... ({remaining} more lines truncated)", lines[..MAX_DIFF_LINES].join("\n")) + format!( + "{}\n... ({remaining} more lines truncated)", + lines[..MAX_DIFF_LINES].join("\n") + ) } #[cfg(test)] @@ -98,7 +108,10 @@ mod tests { #[test] fn test_truncate_diff_over_limit_truncates() { - let diff = (0..250).map(|i| format!("line{i}")).collect::>().join("\n"); + let diff = (0..250) + .map(|i| format!("line{i}")) + .collect::>() + .join("\n"); let result = truncate_diff(&diff); assert!(result.contains("... (50 more lines truncated)")); assert_eq!(result.lines().count(), MAX_DIFF_LINES + 1); diff --git a/src/tool/fs/mod.rs b/src/tool/fs/mod.rs index 7ada844..2bcd1a8 100644 --- a/src/tool/fs/mod.rs +++ b/src/tool/fs/mod.rs @@ -1,6 +1,5 @@ //! Filesystem tool implementations: read, write, edit, and delete operations //! on workspace-rooted paths. - pub mod delete; pub mod edit; pub mod helpers; diff --git a/src/tool/fs/read.rs b/src/tool/fs/read.rs index 710328d..dc7f04e 100644 --- a/src/tool/fs/read.rs +++ b/src/tool/fs/read.rs @@ -1,14 +1,18 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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. - -use std::fs; -use std::path::PathBuf; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; +use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::super::resolve_path; use super::helpers::{arg_str, not_found_help}; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; /// Tool: read a file and display it with line numbers, optionally truncated to `limit` lines. pub struct Read; @@ -48,7 +52,10 @@ impl Tool for Read { /// exist; a "is a directory" message if the path points at a directory. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = arg_str(args, "path")?; - let limit = args.get("limit").and_then(serde_json::Value::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) { Ok(p) => p, Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)), @@ -57,10 +64,12 @@ impl Tool for Read { return Ok(not_found_help(ctx, &path, &rel)); } if path.is_dir() { - return Ok(format!("'{rel}' is a directory, not a file. Use ls or glob to list directory contents.")); + 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) - .map_err(|e| anyhow!("failed to read '{rel}': {e}"))?; + let content = + fs::read_to_string(&path).map_err(|e| anyhow!("failed to read '{rel}': {e}"))?; let lines: Vec<&str> = content.lines().collect(); let total = lines.len(); let take = limit.unwrap_or(total).min(total); @@ -71,7 +80,12 @@ impl Tool for Read { .collect::>() .join("\n"); if take < total { - Ok(format!("{}\n... ({} more lines, total {})", result, total - take, total)) + Ok(format!( + "{}\n... ({} more lines, total {})", + result, + total - take, + total + )) } else if total == 0 { Ok(String::new()) } else { diff --git a/src/tool/fs/write.rs b/src/tool/fs/write.rs index bd3114c..2fcbcd1 100644 --- a/src/tool/fs/write.rs +++ b/src/tool/fs/write.rs @@ -1,14 +1,13 @@ //! Tool: `write` — write content to a file, creating parent directories on demand. - -use std::fs; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; +use super::super::check_graduated_checks; +use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::super::resolve_path; -use super::super::check_graduated_checks; use super::helpers::{self, arg_str}; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; use similar::TextDiff; +use std::fs; /// Tool: write content to a file, auto-creating parent directories as needed. pub struct Write; @@ -65,8 +64,7 @@ impl Tool for Write { fs::create_dir_all(parent) .map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?; } - fs::write(&path, &content) - .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; + fs::write(&path, &content).map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; if !existed_before { ctx.mention_index.push(rel.clone()); } @@ -85,16 +83,32 @@ impl Tool for Write { let text_diff = TextDiff::from_lines(old.as_str(), content.as_str()); let diff_text = format!( "{}", - text_diff.unified_diff().context_radius(3).header(&rel, &rel) + text_diff + .unified_diff() + .context_radius(3) + .header(&rel, &rel) ); format!("\n```diff\n{}\n```", helpers::truncate_diff(&diff_text)) } else { String::new() }; if check_matches.is_empty() { - Ok(format!("wrote {} bytes to {}{}{}", content.len(), rel, lsp_note, diff_note)) + Ok(format!( + "wrote {} bytes to {}{}{}", + content.len(), + rel, + lsp_note, + diff_note + )) } else { - Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}{}", content.len(), rel, lsp_note, check_matches.join(", "), diff_note)) + Ok(format!( + "wrote {} bytes to {}{}. Graduated checks matched: {}{}", + content.len(), + rel, + lsp_note, + check_matches.join(", "), + diff_note + )) } } } @@ -104,7 +118,9 @@ mod tests { use super::*; fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx { - crate::tool::ToolCtx::builder().workspaces(vec![workspace]).build() + crate::tool::ToolCtx::builder() + .workspaces(vec![workspace]) + .build() } fn temp_workspace() -> std::path::PathBuf { @@ -129,7 +145,8 @@ mod tests { let workspace = temp_workspace(); fs::write(workspace.join("existing.txt"), "old content\n").unwrap(); let ctx = test_ctx(workspace.clone()); - let args = json!({"path": "existing.txt", "content": "new content\n", "reason": "test overwrite"}); + let args = + json!({"path": "existing.txt", "content": "new content\n", "reason": "test overwrite"}); let result = Write.run(&ctx, &args).unwrap(); assert!(result.contains("```diff")); assert!(result.contains("-old content")); @@ -153,9 +170,13 @@ mod tests { fn write_creating_a_new_file_appends_to_the_mention_index() { let workspace = temp_workspace(); let ctx = test_ctx(workspace.clone()); - let args = json!({"path": "brand_new.txt", "content": "hi\n", "reason": "test mention index"}); + let args = + json!({"path": "brand_new.txt", "content": "hi\n", "reason": "test mention index"}); Write.run(&ctx, &args).unwrap(); - assert_eq!(ctx.mention_index.snapshot(), vec!["brand_new.txt".to_string()]); + assert_eq!( + ctx.mention_index.snapshot(), + vec!["brand_new.txt".to_string()] + ); fs::remove_dir_all(&workspace).ok(); } @@ -164,7 +185,8 @@ mod tests { let workspace = temp_workspace(); fs::write(workspace.join("existing.txt"), "old\n").unwrap(); let ctx = test_ctx(workspace.clone()); - let args = json!({"path": "existing.txt", "content": "new\n", "reason": "test no duplicate"}); + let args = + json!({"path": "existing.txt", "content": "new\n", "reason": "test no duplicate"}); Write.run(&ctx, &args).unwrap(); assert!(ctx.mention_index.snapshot().is_empty()); fs::remove_dir_all(&workspace).ok(); diff --git a/src/tool/git_cred.rs b/src/tool/git_cred.rs index ed40001..534d7e5 100644 --- a/src/tool/git_cred.rs +++ b/src/tool/git_cred.rs @@ -1,10 +1,9 @@ //! Tool wrapper around `git credential` for store/get/erase operations. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use std::process::Command; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::process::Command; /// Tool that shells out to `git credential ` to store, retrieve, or erase credentials. pub struct GitCred; @@ -41,7 +40,8 @@ impl Tool for GitCred { /// /// Return: combined stdout+stderr on success; error with stderr on non-zero exit. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let operation = args.get("operation") + let operation = args + .get("operation") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: operation"))?; let output = Command::new("git") diff --git a/src/tool/git_operator.rs b/src/tool/git_operator.rs index 14132f4..9ba0696 100644 --- a/src/tool/git_operator.rs +++ b/src/tool/git_operator.rs @@ -1,10 +1,9 @@ //! Generic tool for running arbitrary git subcommands. - -use std::process::Command; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::process::Command; /// Tool that runs `git [args...]` and returns combined stdout/stderr. pub struct GitOperator; @@ -54,11 +53,13 @@ impl Tool for GitOperator { /// Return: trimmed combined output on success; error including exit code and /// stderr on failure. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let operation = args.get("operation") + let operation = args + .get("operation") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: operation"))? .to_string(); - let arg_list: Vec = args.get("args") + let arg_list: Vec = args + .get("args") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() @@ -79,11 +80,20 @@ impl Tool for GitOperator { .map_err(|e| anyhow!("git {operation} failed: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).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() { Ok(combined) } else { - anyhow::bail!("git {} failed (exit {}): {}", operation, output.status.code().unwrap_or(-1), stderr.trim()) + anyhow::bail!( + "git {} failed (exit {}): {}", + operation, + output.status.code().unwrap_or(-1), + stderr.trim() + ) } } } diff --git a/src/tool/git_worktree.rs b/src/tool/git_worktree.rs index eb356f6..eb2eb52 100644 --- a/src/tool/git_worktree.rs +++ b/src/tool/git_worktree.rs @@ -1,10 +1,9 @@ //! Tool for creating git worktrees under the session's worktrees directory. - -use std::process::Command; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::process::Command; /// Tool that creates a new git worktree (`git worktree add`) from a given base ref. pub struct GitWorktree; @@ -43,14 +42,16 @@ impl Tool for GitWorktree { /// Return: success message with combined output on success; error including exit /// code and stderr on failure. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args.get("name") + let name = args + .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: name"))? .to_string(); if name.contains('/') || name.contains('\\') || name.contains("..") { anyhow::bail!("worktree name must not contain path separators or '..'"); } - let base_ref = args.get("base_ref") + let base_ref = args + .get("base_ref") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: base_ref"))? .to_string(); @@ -65,11 +66,21 @@ impl Tool for GitWorktree { .map_err(|e| anyhow!("git worktree add failed: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).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()) }; - if output.status.success() { - Ok(format!("created worktree '{name}' from '{base_ref}'\n{combined}")) + let combined = if stderr.is_empty() { + stdout.trim().to_string() } else { - anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim()) + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + if output.status.success() { + Ok(format!( + "created worktree '{name}' from '{base_ref}'\n{combined}" + )) + } else { + anyhow::bail!( + "git worktree add failed (exit {}): {}", + output.status.code().unwrap_or(-1), + stderr.trim() + ) } } } diff --git a/src/tool/lsp/mod.rs b/src/tool/lsp/mod.rs index 5f360a1..2796c4e 100644 --- a/src/tool/lsp/mod.rs +++ b/src/tool/lsp/mod.rs @@ -1,10 +1,15 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] -use std::fmt::Write; +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] +use anyhow::{anyhow, Result}; use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; +use std::fmt::Write; -use crate::tool::{Tool, ToolCtx}; use crate::app::lsp::path_to_lsp_uri; +use crate::tool::{Tool, ToolCtx}; pub struct LspConnect; @@ -46,39 +51,52 @@ impl Tool for LspConnect { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args.get("name") + let name = args + .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: name"))?; - let command = args.get("command") + let command = args + .get("command") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: command"))?; - let language_id = args.get("language_id") + let language_id = args + .get("language_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: language_id"))?; - let extra_args: Vec = args.get("args") + let extra_args: Vec = args + .get("args") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .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}"))?; - manager.connect(name, command, &extra_args, language_id)?; + manager.connect(command, &extra_args, language_id)?; // Auto-register this server's known extensions so lsp_diagnostics / // lsp_hover / lsp_completion / lsp_definition / lsp_references can // auto-detect it later without an explicit `server` argument. let known_exts = known_extensions_for(language_id); if !known_exts.is_empty() { - manager.register_extensions(name, known_exts); + manager.register_extensions(language_id, known_exts); } - let client_arc = manager.get_client(name); - let caps = client_arc.and_then(|c| { - c.lock().ok().map(|guard| guard.server_capabilities().clone()) - }).unwrap_or_default(); + let client_arc = manager.get_client(language_id); + let caps = client_arc + .and_then(|c| { + c.lock() + .ok() + .map(|guard| guard.server_capabilities().clone()) + }) + .unwrap_or_default(); - let caps_summary = serde_json::to_string_pretty(&caps) - .unwrap_or_else(|_| "{}".to_string()); + let caps_summary = serde_json::to_string_pretty(&caps).unwrap_or_else(|_| "{}".to_string()); Ok(format!( "Connected to LSP server '{name}' (language: {language_id})\nServer capabilities:\n{caps_summary}" @@ -120,10 +138,12 @@ impl Tool for LspDiagnostics { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args.get("path") + let rel_path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; - let text = args.get("text") + let text = args + .get("text") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: text"))?; let server_name = resolve_server_name(ctx, args, rel_path)?; @@ -132,15 +152,20 @@ impl Tool for LspDiagnostics { let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; 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}"))?; - let language_id = manager.get_language_id(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 language_id = manager.get_language_id(server_name).ok_or_else(|| { + anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") + })?; + let client_arc = manager + .get_client(server_name) .ok_or_else(|| anyhow!("LSP server '{server_name}' not found"))?; drop(manager); - let mut client = client_arc.lock() + let mut client = client_arc + .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; match client.collect_diagnostics(&uri, &language_id, text) { @@ -152,7 +177,11 @@ impl Tool for LspDiagnostics { let mut output = String::from("Diagnostics:\n"); for d in &diags_array { let range = d.get("range").and_then(|r| r.get("start")); - let severity = match d.get("severity").and_then(serde_json::Value::as_i64).unwrap_or(0) { + let severity = match d + .get("severity") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + { 1 => "ERROR", 2 => "WARNING", 3 => "INFO", @@ -160,13 +189,40 @@ impl Tool for LspDiagnostics { _ => "NOTE", }; let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?"); - 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(serde_json::Value::as_i64).unwrap_or(0); - 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()))) + 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(serde_json::Value::as_i64) + .unwrap_or(0); + 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()) + }) + }) .unwrap_or(""); - let code_str = if code.is_empty() { String::new() } else { format!(" [{code}]") }; - writeln!(output, " {}:{}:{} - {}{}: {}", rel_path, line + 1, col, severity, code_str, message).unwrap(); + let code_str = if code.is_empty() { + String::new() + } else { + format!(" [{code}]") + }; + writeln!( + output, + " {}:{}:{} - {}{}: {}", + rel_path, + line + 1, + col, + severity, + code_str, + message + ) + .unwrap(); } Ok(output) } @@ -223,15 +279,18 @@ impl Tool for LspHover { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args.get("path") + let rel_path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args.get("line") + let line = args + .get("line") .and_then(serde_json::Value::as_i64) .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + let column = + args.get("column") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = server_name.as_str(); @@ -241,17 +300,23 @@ impl Tool for LspHover { let file_content = std::fs::read_to_string(&abs_path) .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}"))?; - let language_id = manager.get_language_id(server_name) - .unwrap_or_else(|| { - args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string() - }); - let client_arc = manager.get_client(server_name) - .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?; + let language_id = manager.get_language_id(server_name).unwrap_or_else(|| { + args.get("language_id") + .and_then(|v| v.as_str()) + .unwrap_or("plaintext") + .to_string() + }); + let client_arc = manager.get_client(server_name).ok_or_else(|| { + anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") + })?; drop(manager); - let mut client = client_arc.lock() + let mut client = client_arc + .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; client.did_open(&uri, &language_id, 1, &file_content)?; @@ -268,15 +333,22 @@ impl Tool for LspHover { let mut output = String::new(); if let Some(range_val) = range { if let Some(start) = range_val.get("start") { - let rl = start.get("line").and_then(serde_json::Value::as_i64).unwrap_or(0); - let rc = start.get("character").and_then(serde_json::Value::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(serde_json::Value::as_i64) + .unwrap_or(0); writeln!(output, "Range: {}:{}", rl + 1, rc + 1).unwrap(); } } if let Some(contents_val) = contents { output.push_str(&format_hover_contents(contents_val)); } else { - output.push_str(&serde_json::to_string_pretty(&hover_result).unwrap_or_default()); + output + .push_str(&serde_json::to_string_pretty(&hover_result).unwrap_or_default()); } Ok(output) } @@ -352,15 +424,18 @@ impl Tool for LspCompletion { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args.get("path") + let rel_path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args.get("line") + let line = args + .get("line") .and_then(serde_json::Value::as_i64) .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + let column = + args.get("column") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = server_name.as_str(); @@ -370,15 +445,20 @@ impl Tool for LspCompletion { let file_content = std::fs::read_to_string(&abs_path) .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}"))?; - let language_id = manager.get_language_id(server_name) + let language_id = manager + .get_language_id(server_name) .unwrap_or_else(|| "plaintext".to_string()); - let client_arc = manager.get_client(server_name) - .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?; + let client_arc = manager.get_client(server_name).ok_or_else(|| { + anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") + })?; drop(manager); - let mut client = client_arc.lock() + let mut client = client_arc + .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; client.did_open(&uri, &language_id, 1, &file_content)?; @@ -389,7 +469,8 @@ impl Tool for LspCompletion { Ok(completion_result) => { let items = if let Some(items) = completion_result.as_array() { items.clone() - } else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array()) { + } else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array()) + { arr.clone() } else { Vec::new() @@ -399,10 +480,19 @@ impl Tool for LspCompletion { return Ok("No completions available at this position.".to_string()); } - 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) { let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?"); - let kind = match item.get("kind").and_then(serde_json::Value::as_i64).unwrap_or(0) { + let kind = match item + .get("kind") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + { 1 => "Text", 2 => "Method", 3 => "Function", @@ -431,7 +521,11 @@ impl Tool for LspCompletion { _ => "Other", }; 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}") + }; writeln!(output, " {}. [{}] {}{}", i + 1, kind, label, detail_str).unwrap(); } if items.len() > 50 { @@ -482,15 +576,18 @@ impl Tool for LspDefinition { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args.get("path") + let rel_path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args.get("line") + let line = args + .get("line") .and_then(serde_json::Value::as_i64) .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + let column = + args.get("column") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = server_name.as_str(); @@ -500,15 +597,20 @@ impl Tool for LspDefinition { let file_content = std::fs::read_to_string(&abs_path) .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}"))?; - let language_id = manager.get_language_id(server_name) + let language_id = manager + .get_language_id(server_name) .unwrap_or_else(|| "plaintext".to_string()); - let client_arc = manager.get_client(server_name) - .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?; + let client_arc = manager.get_client(server_name).ok_or_else(|| { + anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") + })?; drop(manager); - let mut client = client_arc.lock() + let mut client = client_arc + .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; client.did_open(&uri, &language_id, 1, &file_content)?; @@ -535,8 +637,14 @@ impl Tool for LspDefinition { 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_start = target_range.and_then(|r| r.get("start")); - 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(serde_json::Value::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(serde_json::Value::as_i64) + .unwrap_or(0); let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap(); } @@ -588,15 +696,18 @@ impl Tool for LspReferences { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args.get("path") + let rel_path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args.get("line") + let line = args + .get("line") .and_then(serde_json::Value::as_i64) .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + let column = + args.get("column") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = server_name.as_str(); @@ -606,15 +717,20 @@ impl Tool for LspReferences { let file_content = std::fs::read_to_string(&abs_path) .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}"))?; - let language_id = manager.get_language_id(server_name) + let language_id = manager + .get_language_id(server_name) .unwrap_or_else(|| "plaintext".to_string()); - let client_arc = manager.get_client(server_name) - .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?; + let client_arc = manager.get_client(server_name).ok_or_else(|| { + anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") + })?; drop(manager); - let mut client = client_arc.lock() + let mut client = client_arc + .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; client.did_open(&uri, &language_id, 1, &file_content)?; @@ -632,8 +748,14 @@ impl Tool for LspReferences { for (i, loc) in locations.iter().enumerate().take(50) { 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 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(serde_json::Value::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(serde_json::Value::as_i64) + .unwrap_or(0); let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap(); } @@ -672,11 +794,14 @@ impl Tool for LspDisconnect { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args.get("name") + let name = args + .get("name") .and_then(|v| v.as_str()) .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}"))?; if manager.disconnect(name) { @@ -710,7 +835,7 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] { /// /// Flow: extract extension from `path` -> for each connected server, check /// whether `known_extensions_for(server.language_id)` contains the extension -/// -> return the first match's name. +/// -> return the first match's `language_id`. /// /// This is a fallback used only when the caller omits `server` and the file's /// extension is not (yet) present in `LspManager::extension_registry` — e.g. @@ -718,13 +843,15 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] { /// `None` if the path has no extension, the lock is poisoned, or no /// connected server's language is known to use that extension. fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option { - 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}"); if let Ok(mgr) = ctx.lsp_manager.lock() { for s in &mgr.servers { let exts = known_extensions_for(&s.language_id); if exts.contains(&dot_ext.as_str()) { - return Some(s.name.clone()); + return Some(s.language_id.clone()); } } } @@ -735,8 +862,8 @@ fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option { /// argument if present, otherwise auto-detected from `path`'s extension. /// /// Flow: `args["server"]` present -> use it as-is. Otherwise -> try -/// `LspManager::find_server_for_path`-style registry lookup by delegating to -/// `auto_detect_server`. If that also fails, build a helpful error message +/// registry lookup by delegating to `auto_detect_server`. If that also fails, +/// build a helpful error message /// listing the currently connected servers (via `LspManager::list_servers`) /// so the caller knows whether to connect one first. /// @@ -755,18 +882,26 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result".to_string(), |e| format!(".{e}")); + .and_then(|e| e.to_str()) + .map_or_else(|| "".to_string(), |e| format!(".{e}")); - let available = ctx.lsp_manager.lock().ok() + let available = ctx + .lsp_manager + .lock() + .ok() .map(|mgr| { mgr.list_servers() .iter() - .map(|(name, lang, _)| format!("{name} ({lang})")) + .map(|(lang, _)| lang.clone()) .collect::>() .join(", ") }) .unwrap_or_default(); - let available = if available.is_empty() { "none".to_string() } else { available }; + let available = if available.is_empty() { + "none".to_string() + } else { + available + }; Err(anyhow!( "LSP server not found for extension '{ext}'. Use lsp_connect to connect one. Available servers: {available}" diff --git a/src/tool/memory/forget.rs b/src/tool/memory/forget.rs index 881ccc2..3f36a73 100644 --- a/src/tool/memory/forget.rs +++ b/src/tool/memory/forget.rs @@ -1,10 +1,9 @@ //! Tool for deleting a persisted memory entry by name. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; use crate::model::memory::Memory; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool that removes a single memory entry from `ctx.memory_dir` by exact name. pub struct Forget; @@ -38,7 +37,8 @@ impl Tool for Forget { /// Return: confirmation message on success; error if the memory does not exist /// or the file could not be removed. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args.get("name") + let name = args + .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: name"))?; diff --git a/src/tool/memory/mod.rs b/src/tool/memory/mod.rs index 9afb356..1ba1436 100644 --- a/src/tool/memory/mod.rs +++ b/src/tool/memory/mod.rs @@ -1,5 +1,4 @@ //! Memory tools: `remember`, `recall`, and `forget` for persisted project memory entries. - pub mod forget; pub mod recall; pub mod remember; diff --git a/src/tool/memory/recall.rs b/src/tool/memory/recall.rs index 9dc9444..0871b18 100644 --- a/src/tool/memory/recall.rs +++ b/src/tool/memory/recall.rs @@ -1,11 +1,10 @@ //! Tool for reading a single memory entry or listing the whole memory index. - -use std::fmt::Write; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; use crate::model::memory::Memory; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::fmt::Write; /// Tool that reads one memory entry by name, or lists all entries when name is omitted. pub struct Recall; @@ -46,11 +45,7 @@ impl Tool for Recall { .map_err(|e| anyhow!("memory '{name}' not found: {e}"))?; Ok(format!( "---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}", - memory.name, - memory.description, - memory.kind, - memory.lifecycle, - memory.content, + memory.name, memory.description, memory.kind, memory.lifecycle, memory.content, )) } else { Ok(list_all(ctx)) diff --git a/src/tool/memory/remember.rs b/src/tool/memory/remember.rs index 0ab69e3..948c7c2 100644 --- a/src/tool/memory/remember.rs +++ b/src/tool/memory/remember.rs @@ -1,10 +1,9 @@ //! Tool for saving a new memory entry to persistent project memory. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; use crate::model::memory::Memory; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool that writes a new `Memory` entry (name/description/content/kind) to disk. pub struct Remember; @@ -55,16 +54,20 @@ impl Tool for Remember { /// /// Return: confirmation string on success; error if name is invalid or the write fails. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args.get("name") + let name = args + .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: name"))?; - let description = args.get("description") + let description = args + .get("description") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: description"))?; - let content = args.get("content") + let content = args + .get("content") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: content"))?; - let kind = args.get("kind") + let kind = args + .get("kind") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: kind"))?; @@ -88,7 +91,8 @@ impl Tool for Remember { provenances: vec![], }; - memory.write(&ctx.memory_dir) + memory + .write(&ctx.memory_dir) .map_err(|e| anyhow!("failed to write memory '{name}': {e}"))?; Ok(format!("saved memory '{name}' ({kind})")) diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 6b7c190..9a0437e 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -1,10 +1,9 @@ //! Tool trait, execution context, and the registry of all built-in tools. - -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::AtomicBool; -use serde_json::Value; use anyhow::Result; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; pub mod bash_tools; pub mod fs; @@ -18,8 +17,8 @@ pub mod search; pub mod seqthink; pub mod shell; pub mod shell_filter; -pub mod utility; pub mod spawn; +pub mod utility; pub mod workflow; /// Common interface every agent-invocable tool implements: name, JSON schema, and execution. @@ -51,7 +50,8 @@ pub struct ToolCtx { pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, - pub turn_events: Option>>>, + pub turn_events: + Option>>>, /// Ephemeral findings shared between sibling subagents in a workflow run. /// Set by the workflow engine before spawning subagents; tools like /// `note_finding` write into this vec so later pipeline stages can @@ -99,7 +99,8 @@ pub struct ToolCtxBuilder { pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, - pub turn_events: Option>>>, + pub turn_events: + Option>>>, pub workflow_findings: Option>>>, pub abort_flag: Option>, } @@ -111,7 +112,9 @@ impl Default for ToolCtxBuilder { session_dir: PathBuf::new(), memory_dir: PathBuf::new(), worktrees_dir: PathBuf::new(), - dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())), + dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new( + super::app::state::misc::DirCache::new(), + )), mention_index: super::app::state::misc::MentionIndex::new(), origin: crate::app::state::types::Origin::Main, graduated_checks: Vec::new(), @@ -125,14 +128,26 @@ impl Default for ToolCtxBuilder { impl ToolCtxBuilder { /// Set the session directory. - pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } + pub fn session_dir(mut self, v: PathBuf) -> Self { + self.session_dir = v; + self + } /// Set the workspaces. - pub fn workspaces(mut self, v: Vec) -> Self { self.workspaces = v; self } + pub fn workspaces(mut self, v: Vec) -> Self { + self.workspaces = v; + self + } /// Set the origin (main process vs. daemon-attached). - pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self } + pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { + self.origin = v; + self + } /// Set the workflow-level findings sharing Arc (for subagent-to-subagent /// communication within a workflow run). - pub fn workflow_findings(mut self, v: Option>>>) -> Self { self.workflow_findings = v; self } + pub fn workflow_findings(mut self, v: Option>>>) -> Self { + self.workflow_findings = v; + self + } /// Consume the builder and produce the final `ToolCtx`. pub fn build(self) -> ToolCtx { ToolCtx { @@ -236,13 +251,19 @@ pub fn tool_defs(tools: &[Box]) -> Vec Result { let (ws_idx, path) = if rel.starts_with('[') { - let close = rel.find(']').ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; - let idx: usize = rel[1..close].parse().map_err(|_| anyhow::anyhow!("invalid workspace index"))?; + let close = rel + .find(']') + .ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; + let idx: usize = rel[1..close] + .parse() + .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; (idx, &rel[close + 1..]) } else { (0, rel) }; - let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; + let base = workspaces + .get(ws_idx) + .ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; let abs = if path.is_empty() { base.clone() } else { @@ -253,9 +274,12 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { // workspace root first and then resolve parent-dir (`../`) traversal // component-by-component so that `Path::starts_with` cannot be // bypassed by unnormalised intermediate segments. - let canon = if let Ok(c) = abs.canonicalize() { c } else { + let canon = if let Ok(c) = abs.canonicalize() { + c + } else { let base_canon = workspaces - .iter().find_map(|w| w.canonicalize().ok()) + .iter() + .find_map(|w| w.canonicalize().ok()) .unwrap_or_else(|| base.clone()); let mut resolved = base_canon.clone(); if let Ok(rel_components) = abs.strip_prefix(&base_canon) { diff --git a/src/tool/plan.rs b/src/tool/plan.rs index 035096f..71abc34 100644 --- a/src/tool/plan.rs +++ b/src/tool/plan.rs @@ -1,9 +1,8 @@ //! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool the model calls to present a step-by-step plan and enter plan mode. pub struct PlanEnter; @@ -39,10 +38,12 @@ impl Tool for PlanEnter { /// /// Return: fixed acknowledgement string on success; error if either arg is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let _ = args.get("plan") + let _ = args + .get("plan") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: plan"))?; - let _ = args.get("sign_off") + let _ = args + .get("sign_off") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: sign_off"))?; Ok("plan recorded".to_string()) @@ -78,7 +79,8 @@ impl Tool for PlanReady { /// /// Return: fixed "ready to execute" string on success; error if `confirmation` is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let _ = args.get("confirmation") + let _ = args + .get("confirmation") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: confirmation"))?; Ok("ready to execute".to_string()) diff --git a/src/tool/search.rs b/src/tool/search.rs index e4942c6..f954866 100644 --- a/src/tool/search.rs +++ b/src/tool/search.rs @@ -1,13 +1,12 @@ //! Text search tools: `grep` (line matching) and `glob` (filename pattern matching). - -use std::fs; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use ignore::Walk; -use globset::{GlobBuilder, GlobSetBuilder}; +use super::resolve_path; use super::Tool; use super::ToolCtx; -use super::resolve_path; +use anyhow::{anyhow, Result}; +use globset::{GlobBuilder, GlobSetBuilder}; +use ignore::Walk; +use serde_json::{json, Value}; +use std::fs; /// Tool that recursively searches text files under a directory for a literal substring. pub struct Grep; @@ -49,11 +48,13 @@ impl Tool for Grep { /// /// Return: "no matches found" if empty, else a header + `path:line:text` rows. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let pattern = args.get("pattern") + let pattern = args + .get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: pattern"))? .to_string(); - let rel = args.get("path") + let rel = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))? .to_string(); @@ -73,7 +74,8 @@ impl Tool for Grep { if let Ok(content) = fs::read_to_string(file_path) { for (i, line) in content.lines().enumerate() { if line.contains(&pattern) { - let rel_path = file_path.strip_prefix(&path) + let rel_path = file_path + .strip_prefix(&path) .unwrap_or(file_path) .display() .to_string(); @@ -85,7 +87,8 @@ impl Tool for Grep { if results.is_empty() { 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}")) .collect::>() .join("\n"); @@ -134,11 +137,13 @@ impl Tool for Glob { /// /// Return: sorted newline-joined matches; "no files match" sentinel if empty. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let pat_str = args.get("pattern") + let pat_str = args + .get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: pattern"))? .to_string(); - let rel = args.get("path") + let rel = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))? .to_string(); @@ -148,18 +153,19 @@ impl Tool for Glob { } let mut builder = GlobSetBuilder::new(); let full_pattern = root.join(&pat_str).display().to_string(); - builder.add(GlobBuilder::new(&full_pattern).build() - .map_err(|e| anyhow!("invalid glob pattern '{pat_str}': {e}"))?); - let glob_set = builder.build() + builder.add( + GlobBuilder::new(&full_pattern) + .build() + .map_err(|e| anyhow!("invalid glob pattern '{pat_str}': {e}"))?, + ); + let glob_set = builder + .build() .map_err(|e| anyhow!("failed to build glob set: {e}"))?; let mut matches: Vec = Vec::new(); for entry in Walk::new(&root).flatten() { let p = entry.path(); if glob_set.is_match(p) { - let rel_path = p.strip_prefix(&root) - .unwrap_or(p) - .display() - .to_string(); + let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string(); matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" })); } } diff --git a/src/tool/seqthink.rs b/src/tool/seqthink.rs index c5b0152..491f983 100644 --- a/src/tool/seqthink.rs +++ b/src/tool/seqthink.rs @@ -1,9 +1,8 @@ //! Sequential-thinking tool: a no-side-effect echo that records reasoning steps. - -use serde_json::{json, Value}; -use anyhow::Result; use super::Tool; use super::ToolCtx; +use anyhow::Result; +use serde_json::{json, Value}; /// Tool that accepts a reasoning step and returns it verbatim, giving the model a /// structured way to surface its thought chain to the TUI. diff --git a/src/tool/shell.rs b/src/tool/shell.rs index 74d24b9..4eb6fd9 100644 --- a/src/tool/shell.rs +++ b/src/tool/shell.rs @@ -1,11 +1,10 @@ //! Bash-shell execution tool with safety filters and optional timeout. - -use std::process::Command; -use std::time::Duration; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::process::Command; +use std::time::Duration; /// Tool that runs `bash -c `, optionally in the background, with safety /// filters applied before spawning. @@ -59,17 +58,25 @@ impl Tool for Bash { /// Return: exit-code + elapsed-seconds summary line (plus captured output) for /// foreground runs, or the job ID for background runs. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let cmd = args.get("command") + let cmd = args + .get("command") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: command"))? .to_string(); - let timeout_ms = args.get("timeout").and_then(serde_json::Value::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 // locally since the AI needs access, and the real threat is committing // secrets to a public repo (handled by git pre-commit hooks / user). super::shell_filter::git::check_git_destructive(&cmd) .map_err(|e| anyhow!("blocked: {e}"))?; - let run_in_background = args.get("run_in_background").and_then(serde_json::Value::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 { let job = crate::app::bgbash::job::spawn_bash_job(cmd); return Ok(format!("Background job: {}", job.id)); @@ -87,11 +94,16 @@ impl Tool for Bash { match child.try_wait() { Ok(Some(status)) => { 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}"))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") }; + let combined = if stderr.is_empty() { + stdout + } else { + format!("{stdout}\n{stderr}") + }; let trimmed = combined.trim().to_string(); if status.success() { return Ok(if trimmed.is_empty() { @@ -100,7 +112,12 @@ impl Tool for Bash { 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 + )); } Ok(None) => { if start.elapsed() > timeout { diff --git a/src/tool/shell_filter/credentials.rs b/src/tool/shell_filter/credentials.rs index 049da23..4ce2c1a 100644 --- a/src/tool/shell_filter/credentials.rs +++ b/src/tool/shell_filter/credentials.rs @@ -1,5 +1,4 @@ //! Block shell commands that try to read common credential files or secrets. - use anyhow::Result; /// Reject shell commands whose lowercased form contains any known credential-read pattern. diff --git a/src/tool/shell_filter/git.rs b/src/tool/shell_filter/git.rs index d8c9d83..5c0fc96 100644 --- a/src/tool/shell_filter/git.rs +++ b/src/tool/shell_filter/git.rs @@ -1,5 +1,4 @@ //! Block shell commands that perform destructive or hard-to-reverse git operations. - use anyhow::Result; /// Reject shell commands whose lowercased form contains any known destructive git pattern. @@ -45,7 +44,8 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> { "push --tags --force", ]; let cmd_lower = cmd.to_lowercase(); - let cmd_no_quotes: String = cmd_lower.chars() + let cmd_no_quotes: String = cmd_lower + .chars() .filter(|&c| c != '\'' && c != '"') .collect(); // Normalize ANSI-C quoting ($'...') which can encode spaces and @@ -55,7 +55,10 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> { // inside $'...' blocks, then substitute the decoded text. let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes); 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}'"); } } diff --git a/src/tool/shell_filter/mod.rs b/src/tool/shell_filter/mod.rs index 98dd8c7..3aba5eb 100644 --- a/src/tool/shell_filter/mod.rs +++ b/src/tool/shell_filter/mod.rs @@ -1,5 +1,4 @@ //! Pre-execution safety filters applied to shell commands before they're spawned. - pub mod git; /// Decode ANSI-C quoted strings ($'...') found in `input`, replacing @@ -26,7 +25,10 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String { None | Some('\'') => break, Some('\\') => { match chars.next() { - None => { decoded.push('\\'); break; } + None => { + decoded.push('\\'); + break; + } Some('n') => decoded.push('\n'), Some('t') => decoded.push('\t'), Some('r') => decoded.push('\r'), @@ -34,7 +36,11 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String { Some('\'') => decoded.push('\''), Some('x' | 'X') => { // \xHH — hex escape (2 hex digits) - let hex: String = chars.by_ref().take(2).take_while(char::is_ascii_hexdigit).collect(); + let hex: String = chars + .by_ref() + .take(2) + .take_while(char::is_ascii_hexdigit) + .collect(); if hex.len() == 2 { if let Ok(byte) = u8::from_str_radix(&hex, 16) { decoded.push(byte as char); @@ -47,7 +53,11 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String { } Some('u') => { // \uNNNN — unicode escape (4 hex digits) - let hex: String = chars.by_ref().take(4).take_while(char::is_ascii_hexdigit).collect(); + let hex: String = chars + .by_ref() + .take(4) + .take_while(char::is_ascii_hexdigit) + .collect(); if hex.len() == 4 { if let Ok(code) = u32::from_str_radix(&hex, 16) { if let Some(c) = char::from_u32(code) { @@ -66,7 +76,9 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String { for _ in 0..2 { match chars.peek() { Some(c) if c.is_ascii_digit() && *c >= '0' && *c <= '7' => { - oct.push(chars.next().unwrap()); + if let Some(c) = chars.next() { + oct.push(c); + } } _ => break, } diff --git a/src/tool/spawn.rs b/src/tool/spawn.rs index 44a70e7..2b38875 100644 --- a/src/tool/spawn.rs +++ b/src/tool/spawn.rs @@ -8,18 +8,19 @@ //! //! Also provides a pipeline variant: `spawn_pipeline` runs agents //! sequentially so each stage sees the previous stage's findings. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use std::collections::HashMap; use super::{Tool, ToolCtx}; -use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript}; +use crate::app::workflow::script::{ScriptOptions, ScriptPrimitive, WorkflowScript}; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::collections::HashMap; /// Fan out a list of prompts to independent parallel subagents. pub struct SpawnAgents; impl Tool for SpawnAgents { - fn name(&self) -> &'static str { "spawn_agents" } + fn name(&self) -> &'static str { + "spawn_agents" + } fn description(&self) -> &'static str { "Fan out independent subtasks to multiple Hive nodes running in PARALLEL. \ @@ -53,7 +54,8 @@ impl Tool for SpawnAgents { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { use std::sync::{Arc, Mutex}; - let agents: Vec = args.get("agents") + let agents: Vec = args + .get("agents") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow!("missing required argument: agents"))? .iter() @@ -64,18 +66,19 @@ impl Tool for SpawnAgents { return Err(anyhow!("agents list must not be empty")); } if agents.len() == 1 { - return Err(anyhow!("use a single agent tool call for one task; spawn_agents is for 2+ parallel tasks")); + return Err(anyhow!( + "use a single agent tool call for one task; spawn_agents is for 2+ parallel tasks" + )); } - let max_concurrency = args.get("max_concurrency") + let max_concurrency = args + .get("max_concurrency") .and_then(serde_json::Value::as_u64) .map_or(10, |v| v.min(10) as usize); let agent_count = agents.len(); - let primitives: Vec = agents - .into_iter() - .map(ScriptPrimitive::Agent) - .collect(); + let primitives: Vec = + agents.into_iter().map(ScriptPrimitive::Agent).collect(); let wf = WorkflowScript { name: format!("parallel-{agent_count}-agents"), @@ -88,19 +91,23 @@ impl Tool for SpawnAgents { }, }; - let live: Option = ctx.turn_events.as_ref().map(|turn_events| { - let turn_events = turn_events.clone(); - 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() { - q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status, + let live: Option = + ctx.turn_events.as_ref().map(|turn_events| { + let turn_events = turn_events.clone(); + 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() { + q.push_back( + crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status, + }, + ); + } }); - } + f }); - f - }); // Create a per-invocation findings scope so subagents spawned // by this tool call are isolated from any other concurrent @@ -127,7 +134,9 @@ impl Tool for SpawnAgents { pub struct SpawnPipeline; impl Tool for SpawnPipeline { - fn name(&self) -> &'static str { "spawn_pipeline" } + fn name(&self) -> &'static str { + "spawn_pipeline" + } fn description(&self) -> &'static str { "Run Hive nodes SEQUENTIALLY in a pipeline — each stage sees findings \ @@ -153,7 +162,8 @@ impl Tool for SpawnPipeline { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { use std::sync::{Arc, Mutex}; - let stages: Vec = args.get("stages") + let stages: Vec = args + .get("stages") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow!("missing required argument: stages"))? .iter() @@ -164,10 +174,8 @@ impl Tool for SpawnPipeline { return Err(anyhow!("stages list must not be empty")); } - let primitives: Vec = stages - .into_iter() - .map(ScriptPrimitive::Agent) - .collect(); + let primitives: Vec = + stages.into_iter().map(ScriptPrimitive::Agent).collect(); let wf = WorkflowScript { name: "pipeline".to_string(), @@ -180,19 +188,23 @@ impl Tool for SpawnPipeline { }, }; - let live: Option = ctx.turn_events.as_ref().map(|turn_events| { - let turn_events = turn_events.clone(); - 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() { - q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate { - agent_id, - agent_name, - status, + let live: Option = + ctx.turn_events.as_ref().map(|turn_events| { + let turn_events = turn_events.clone(); + 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() { + q.push_back( + crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status, + }, + ); + } }); - } + f }); - f - }); // Per-invocation findings scope isolates this pipeline from any // other concurrent spawn_agents / spawn_pipeline / workflow_run. diff --git a/src/tool/utility/cd.rs b/src/tool/utility/cd.rs index 19712b8..8936d62 100644 --- a/src/tool/utility/cd.rs +++ b/src/tool/utility/cd.rs @@ -1,9 +1,8 @@ //! `cd` tool: verify and resolve a workspace-relative directory path. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool that resolves a workspace-relative path and reports whether it exists and is a dir. pub struct Cd; @@ -41,17 +40,26 @@ impl Tool for Cd { /// Return: canonical path on success; explicit "does not exist" / "not a directory" /// message (still `Ok`) so the model can react without treating it as an error. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = args.get("path") + let rel = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; let path = super::super::resolve_path(&ctx.workspaces, rel)?; if !path.exists() { - return Ok(format!("path '{}' does not exist (resolved to {})", rel, path.display())); + return Ok(format!( + "path '{}' does not exist (resolved to {})", + rel, + path.display() + )); } if !path.is_dir() { - return Ok(format!("path '{}' is not a directory (resolved to {})", rel, path.display())); + return Ok(format!( + "path '{}' is not a directory (resolved to {})", + rel, + path.display() + )); } let canon = path.canonicalize().unwrap_or(path); diff --git a/src/tool/utility/dir_cache_update.rs b/src/tool/utility/dir_cache_update.rs index 19e6841..8c9d67a 100644 --- a/src/tool/utility/dir_cache_update.rs +++ b/src/tool/utility/dir_cache_update.rs @@ -7,11 +7,10 @@ //! //! Why: other tools rely on this cache for faster path resolution, so //! it must be kept fresh on demand rather than only populated at startup. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool that refreshes the shared directory cache for a given path. pub struct DirCacheUpdate; @@ -52,14 +51,19 @@ impl Tool for DirCacheUpdate { /// Return: a confirmation string with the entry count, or an error if /// the `path` argument is missing or the temp runtime fails to start. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = args.get("path") + let rel = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; let path = super::super::resolve_path(&ctx.workspaces, rel)?; if !path.exists() { - return Ok(format!("path '{}' does not exist (resolved to {})", rel, path.display())); + return Ok(format!( + "path '{}' does not exist (resolved to {})", + rel, + path.display() + )); } let entries = walk_directory(&path); diff --git a/src/tool/utility/dir_list.rs b/src/tool/utility/dir_list.rs index 8dd6354..432d1f9 100644 --- a/src/tool/utility/dir_list.rs +++ b/src/tool/utility/dir_list.rs @@ -7,12 +7,11 @@ //! //! Why: gives the agent a quick, one-level view of the workspace //! structure without pulling in the full recursive directory cache. - -use std::fs; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::fs; /// Tool that lists the immediate contents of a workspace directory. pub struct DirList; @@ -53,17 +52,26 @@ impl Tool for DirList { /// Return: header + newline-joined entry names, or an error if the /// `path` argument is missing or `read_dir` fails outright. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = args.get("path") + let rel = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))?; let path = super::super::resolve_path(&ctx.workspaces, rel)?; if !path.exists() { - return Ok(format!("path '{}' does not exist (resolved to {})", rel, path.display())); + return Ok(format!( + "path '{}' does not exist (resolved to {})", + rel, + path.display() + )); } if !path.is_dir() { - return Ok(format!("path '{}' is not a directory (resolved to {})", rel, path.display())); + return Ok(format!( + "path '{}' is not a directory (resolved to {})", + rel, + path.display() + )); } let entries: Vec = fs::read_dir(&path) diff --git a/src/tool/utility/mod.rs b/src/tool/utility/mod.rs index 1f1568d..a753993 100644 --- a/src/tool/utility/mod.rs +++ b/src/tool/utility/mod.rs @@ -1,8 +1,7 @@ //! Small standalone utility tools (cd, dir listing/caching, pong, todowrite). - pub mod cd; pub mod dir_cache_update; pub mod dir_list; pub mod pong; -pub mod todowrite; pub mod todofinish; +pub mod todowrite; diff --git a/src/tool/utility/pong.rs b/src/tool/utility/pong.rs index 6f0b4c5..bf6cd04 100644 --- a/src/tool/utility/pong.rs +++ b/src/tool/utility/pong.rs @@ -5,11 +5,10 @@ //! //! Why: gives callers a cheap, dependency-free way to verify the tool //! harness is reachable and responding before running real work. - -use serde_json::{json, Value}; -use anyhow::Result; use super::super::Tool; use super::super::ToolCtx; +use anyhow::Result; +use serde_json::{json, Value}; /// Tool that echoes back a message; used for connectivity/latency checks. pub struct Pong; @@ -36,7 +35,8 @@ impl Tool for Pong { } fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let msg = args.get("message") + let msg = args + .get("message") .and_then(|v| v.as_str()) .unwrap_or("pong"); Ok(format!("pong: {msg}")) diff --git a/src/tool/utility/todofinish.rs b/src/tool/utility/todofinish.rs index 31481f6..23ba38d 100644 --- a/src/tool/utility/todofinish.rs +++ b/src/tool/utility/todofinish.rs @@ -1,9 +1,8 @@ //! Tool for marking tasks as finished in the session's todo list. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use std::path::PathBuf; use super::super::{Tool, ToolCtx}; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::path::PathBuf; /// Tool that marks tasks as finished in the session's todo.md. pub struct Todofinish; @@ -35,8 +34,8 @@ impl Tool for Todofinish { return Ok("No todo.md found in session directory. Nothing to finish.".to_string()); } - let content = std::fs::read_to_string(&path) - .map_err(|e| anyhow!("failed to read todo.md: {e}"))?; + let content = + std::fs::read_to_string(&path).map_err(|e| anyhow!("failed to read todo.md: {e}"))?; let task_index = args.get("task_index").and_then(serde_json::Value::as_i64); diff --git a/src/tool/utility/todowrite.rs b/src/tool/utility/todowrite.rs index 0e05400..bf34577 100644 --- a/src/tool/utility/todowrite.rs +++ b/src/tool/utility/todowrite.rs @@ -7,13 +7,12 @@ //! Why: the file lives under `ctx.session_dir` so it persists per //! session and is picked up by the TUI's Todo panel; appending (rather //! than rewriting) keeps prior tasks intact. - -use std::fs; -use std::path::PathBuf; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; /// Tool that appends a timestamped task line to the session's todo.md. pub struct Todowrite; @@ -52,7 +51,8 @@ impl Tool for Todowrite { /// Return: confirmation string echoing the added task, or an error /// if the `task` argument is missing or the file can't be opened/written. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let task = args.get("task") + let task = args + .get("task") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: task"))?; diff --git a/src/tool/workflow.rs b/src/tool/workflow.rs index 09fb4e9..1d84c43 100644 --- a/src/tool/workflow.rs +++ b/src/tool/workflow.rs @@ -11,11 +11,10 @@ //! out independent subtasks (parallel/pipeline/phased) instead of the //! agent handling everything inline; simple tasks should skip this tool //! entirely per its own description string. - -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; /// Tool that parses and executes a JSON-encoded workflow script (Agent/Parallel/Pipeline/Phase). pub struct WorkflowRun; @@ -59,7 +58,8 @@ impl Tool for WorkflowRun { /// Return: the workflow engine's output string, or an error if the /// script argument is missing or fails to parse as JSON. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let script_str = args.get("script") + let script_str = args + .get("script") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: script"))?; @@ -67,17 +67,21 @@ impl Tool for WorkflowRun { serde_json::from_str(script_str) .map_err(|e| anyhow!("failed to parse workflow script: {e}"))?; - let workflow_args: std::collections::HashMap = args.get("args") + let workflow_args: std::collections::HashMap = args + .get("args") .and_then(|v| v.as_object()) .map(|obj| { - obj.iter().filter_map(|(k, v)| { - v.as_str().map(|s| (k.clone(), s.to_string())) - }).collect() + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() }) .unwrap_or_default(); crate::app::workflow::engine::run_workflow( - &workflow_script, &workflow_args, &ctx.session_dir, &ctx.workspaces, + &workflow_script, + &workflow_args, + &ctx.session_dir, + &ctx.workspaces, ) } } @@ -121,7 +125,8 @@ impl Tool for NoteFinding { /// Return: confirmation string containing up to the first 80 chars /// of the recorded text. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let text = args.get("text") + let text = args + .get("text") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: text"))?; @@ -135,7 +140,10 @@ impl Tool for NoteFinding { text.chars().take(80).collect::(), ); } - Ok(format!("finding recorded: {}", text.chars().take(80).collect::())) + Ok(format!( + "finding recorded: {}", + text.chars().take(80).collect::() + )) } } @@ -203,16 +211,18 @@ impl Tool for HiveMind { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let request = args.get("request") + let request = args + .get("request") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: request"))?; - let cycles_value = args.get("cycles") + let cycles_value = args + .get("cycles") .ok_or_else(|| anyhow!("missing required argument: cycles"))?; - let plan: crate::app::workflow::hive_mind::CognitiveCyclePlan = serde_json::from_value( - json!({ "cycles": cycles_value }) - ).map_err(|e| anyhow!("failed to parse cycles: {e}"))?; + let plan: crate::app::workflow::hive_mind::CognitiveCyclePlan = + serde_json::from_value(json!({ "cycles": cycles_value })) + .map_err(|e| anyhow!("failed to parse cycles: {e}"))?; // run_hive_mind now writes the docs/runs/*.md convergence report // itself (guaranteed, even if synthesis fails) — do not write it @@ -264,8 +274,7 @@ impl Tool for ReadFindings { Ok(format!("Hive findings in this run:\n{formatted}")) } } else { - Ok("No Hive collective state available (called outside a Hive run).".to_string()) + Ok("No Hive collective state available (called outside a Hive run).".to_string()) } } } - diff --git a/src/view/chat.rs b/src/view/chat.rs index 9b3ba05..7cb2115 100644 --- a/src/view/chat.rs +++ b/src/view/chat.rs @@ -1,4 +1,9 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Chat transcript panel rendering — tight inline log style. //! //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense, @@ -14,14 +19,13 @@ //! short colored label, and vertical space is reserved for a blank line //! only when the speaker actually changes (Tool sub-lines never count as //! a speaker change), keeping more history on screen at once. - +use super::theme::Theme; +use crate::dto::chat::message::Role; use ratatui::layout::Rect; -use ratatui::style::{Color, Style, Modifier}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::Frame; -use super::theme::Theme; -use crate::dto::chat::message::Role; /// Column width reserved for the `{role} {time} ` header prefix; wrapped /// continuation lines and Tool sub-lines indent to this width so content @@ -29,6 +33,10 @@ use crate::dto::chat::message::Role; const PREFIX_WIDTH: usize = 15; /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. +/// +/// Flow: iterate spans, split each span's content on `\n` -> for each segment +/// build up a line, pushing completed lines when a `\n` boundary is reached. +/// Returns at least one (possibly empty) line. fn split_spans_into_lines(spans: Vec>) -> Vec> { let mut lines = Vec::new(); let mut current_spans = Vec::new(); @@ -54,6 +62,7 @@ fn split_spans_into_lines(spans: Vec>) -> Vec> { lines } +/// Return the accent color associated with a chat message role for the role label. fn role_accent_color(role: &Role) -> Color { match role { Role::User => Theme::ROLE_USER, @@ -75,8 +84,11 @@ fn format_role_label(role: &Role) -> &'static str { } } +/// Format a millisecond timestamp as `HH:MM`. Returns empty string for non-positive values. fn format_timestamp(ts: i64) -> String { - if ts <= 0 { return String::new(); } + if ts <= 0 { + return String::new(); + } let secs = ts / 1000; let mins = (secs / 60) % 60; let hrs = (secs / 3600) % 24; @@ -95,7 +107,6 @@ fn needs_speaker_separator(_prev_role: Option<&Role>, _role: &Role) -> bool { } /// Render the scrollable chat transcript panel in tight inline-log style. -#[allow(clippy::too_many_lines)] pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; @@ -136,10 +147,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: let mut lines_iter = content_lines.into_iter(); let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans); - let mut spans = vec![ - Span::raw(" ".repeat(PREFIX_WIDTH)), - Span::styled("↳ ", dim), - ]; + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)]; spans.extend(first_spans); display_lines.push(Line::from(spans)); @@ -160,8 +168,14 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: let label = format_role_label(&msg.role); let ts_str = format_timestamp(msg.timestamp); let header_prefix = vec![ - Span::styled(format!("{label} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)), - Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)), + Span::styled( + format!("{label} "), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{ts_str:<5} "), + Style::default().fg(Theme::TEXT_DIM), + ), ]; let content_str = if msg.content.trim().is_empty() { @@ -205,10 +219,17 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: display_lines.push(Line::from(vec![ Span::styled( format!("{} ", format_role_label(&Role::Assistant)), - Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), + Style::default() + .fg(Theme::ROLE_ASSISTANT) + .add_modifier(Modifier::BOLD), ), Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)), - Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)), + Span::styled( + "generating...", + Style::default() + .fg(Theme::TEXT_MUTED) + .add_modifier(Modifier::ITALIC), + ), ])); } @@ -217,7 +238,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD))); + .title(Span::styled( + title, + Style::default() + .fg(Theme::TEXT_MUTED) + .add_modifier(Modifier::BOLD), + )); let total = display_lines.len(); let max_offset = total.saturating_sub(max_visible); @@ -243,7 +269,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD))) + .title(Span::styled( + scroll_title, + Style::default() + .fg(Theme::TEXT_MUTED) + .add_modifier(Modifier::BOLD), + )) } else { block }; @@ -266,12 +297,18 @@ mod tests { #[test] fn no_separator_when_same_speaker_repeats() { - assert!(!needs_speaker_separator(Some(&Role::Assistant), &Role::Assistant)); + assert!(!needs_speaker_separator( + Some(&Role::Assistant), + &Role::Assistant + )); } #[test] fn no_separator_when_speaker_changes_because_zsh_style() { - assert!(!needs_speaker_separator(Some(&Role::User), &Role::Assistant)); + assert!(!needs_speaker_separator( + Some(&Role::User), + &Role::Assistant + )); } #[test] diff --git a/src/view/markdown.rs b/src/view/markdown.rs index 859068c..5423e9d 100644 --- a/src/view/markdown.rs +++ b/src/view/markdown.rs @@ -177,7 +177,7 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec> table_rows.push(std::mem::take(&mut current_row)); } pulldown_cmark::TagEnd::Table => { - let cols_count = table_rows.first().map(|r| r.len()).unwrap_or(0); + let cols_count = table_rows.first().map_or(0, std::vec::Vec::len); if cols_count == 0 { continue; } @@ -217,7 +217,7 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec> } } - let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1); + let max_height = cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1); for y in 0..max_height { spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim))); diff --git a/src/view/mod.rs b/src/view/mod.rs index 84a24b6..828041b 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -171,7 +171,7 @@ fn render_overlay( Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!(" Review: {}", state.settings.review_enabled), + format!(" Review: {}", state.settings.flags.review_enabled), Style::default().fg(Theme::TEXT), )), ]; diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs index 641414c..b7f1083 100644 --- a/src/view/sidebar.rs +++ b/src/view/sidebar.rs @@ -2,13 +2,12 @@ //! widgets stacked in three vertical thirds — the "glance" view that //! complements the `Overlay::Todo` / `Overlay::Usage` "expand" views in //! `view/mod.rs`. - +use super::theme::Theme; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Style, Modifier}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; -use super::theme::Theme; /// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage /// widgets stacked in three roughly-equal vertical thirds. @@ -43,7 +42,12 @@ pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::re /// to whatever fits with a trailing "+N more" hint pointing at `/todo`. fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let block = Block::default() - .title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) + .title(Span::styled( + " Tasks ", + Style::default() + .fg(Theme::ACCENT_PURPLE) + .add_modifier(Modifier::BOLD), + )) .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)); let budget = (block.inner(area).height as usize).max(1); @@ -52,13 +56,26 @@ fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::r let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); let lines: Vec = if task_lines.is_empty() { - vec![Line::from(Span::styled(" No tasks yet.", Style::default().fg(Theme::TEXT_DIM)))] + vec![Line::from(Span::styled( + " No tasks yet.", + Style::default().fg(Theme::TEXT_DIM), + ))] } else { let show_hint = task_lines.len() > budget; - let item_budget = if show_hint { budget.saturating_sub(1).max(1) } else { budget }; + let item_budget = if show_hint { + budget.saturating_sub(1).max(1) + } else { + budget + }; let (visible, hidden) = super::split_for_display(&task_lines, item_budget); - let mut lines: Vec = visible.iter() - .map(|l| Line::from(Span::styled(format!(" {l}"), Style::default().fg(Theme::TEXT)))) + let mut lines: Vec = visible + .iter() + .map(|l| { + Line::from(Span::styled( + format!(" {l}"), + Style::default().fg(Theme::TEXT), + )) + }) .collect(); if show_hint { lines.push(super::overflow_hint_line(hidden, "/todo")); @@ -84,7 +101,12 @@ fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::r /// counts reach 5-6 digits, which is routine for an agent session. fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let block = Block::default() - .title(Span::styled(" Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) + .title(Span::styled( + " Usage ", + Style::default() + .fg(Theme::INFO) + .add_modifier(Modifier::BOLD), + )) .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)); @@ -94,7 +116,9 @@ fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::r vec![ Line::from(Span::styled( format!(" {:>6}: {} tok", "total", summary.total_tokens), - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + Style::default() + .fg(Theme::TEXT) + .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( format!(" {:>6}: {} tok", "main", summary.main_tokens), @@ -109,12 +133,18 @@ fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::r Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( - format!(" {:>6}: {}h {:02}m {:02}s", "time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds), + format!( + " {:>6}: {}h {:02}m {:02}s", + "time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds + ), Style::default().fg(Theme::TEXT_DIM), )), ] } else { - vec![Line::from(Span::styled(" No active session.", Style::default().fg(Theme::TEXT_DIM)))] + vec![Line::from(Span::styled( + " No active session.", + Style::default().fg(Theme::TEXT_DIM), + ))] }; let paragraph = Paragraph::new(lines).block(block); diff --git a/src/view/status.rs b/src/view/status.rs index c35f17f..9735724 100644 --- a/src/view/status.rs +++ b/src/view/status.rs @@ -1,4 +1,9 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![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. //! //! Flow: `draw_status_bar` reads live connection/turn state off @@ -8,13 +13,12 @@ //! //! Design: the status bar uses a dark background with carefully //! spaced segments so information is scannable at a glance. - +use super::theme::Theme; use ratatui::layout::Rect; -use ratatui::style::{Style, Modifier}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Block; use ratatui::Frame; -use super::theme::Theme; /// Render the single-line status bar. /// @@ -22,7 +26,11 @@ use super::theme::Theme; /// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI) /// CENTER: spinner + optional contextual info /// 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -56,17 +64,23 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: ]; // ── Right segment: metadata ─────────────────────────────────────────── - let max_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); + let max_tokens = + crate::app::runtime::context::window::resolve(&state.app_config, &state.settings); let right_str = if let Some(ref rt) = state.session_runtime { - let current_tokens: usize = rt.messages.iter() + let current_tokens: usize = rt + .messages + .iter() .filter_map(|m| m.content.as_deref()) .map(crate::app::runtime::context::tokens::count_tokens) .sum(); let mut parts = Vec::new(); 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 + )); } parts.push(format!("{current_tokens}/{max_tokens}")); parts.push(state.settings.provider.clone()); @@ -74,7 +88,10 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: format!(" {} ", parts.join(" · ")) } else { - format!(" 0/{} · {} · {} ", max_tokens, state.settings.provider, state.settings.model) + format!( + " 0/{} · {} · {} ", + max_tokens, state.settings.provider, state.settings.model + ) }; // ── Combine everything ──────────────────────────────────────────────── @@ -86,12 +103,12 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: )); let center_line = if state.misc.lesson_running { - Line::from(vec![ - Span::styled( - " 📘 Generating Lesson... ", - Style::default().fg(Theme::MODE_YOLO).add_modifier(Modifier::BOLD), - ) - ]) + Line::from(vec![Span::styled( + " 📘 Generating Lesson... ", + Style::default() + .fg(Theme::MODE_YOLO) + .add_modifier(Modifier::BOLD), + )]) } else { Line::from("") }; @@ -106,12 +123,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: ]) .split(area); - let block = Block::default() - .style( - Style::default() - .bg(Theme::STATUS_BAR_BG) - .fg(Theme::TEXT), - ); + let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT)); // Left part let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone()); diff --git a/src/view/theme.rs b/src/view/theme.rs index d1eed9b..8a44390 100644 --- a/src/view/theme.rs +++ b/src/view/theme.rs @@ -8,7 +8,6 @@ //! purple accents (not neon) — the popular Tokyo Night editor/terminal //! theme. Chosen for a calmer "professional dev tool" read in place of //! the previous neon-accented palette. - use ratatui::style::Color; /// Central palette of terminal colors used across all TUI render functions. @@ -59,10 +58,10 @@ impl Theme { pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61); // ── Role badge colors ──────────────────────────────────────────────── - pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a); // green + pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a); // green pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); // blue - pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff); // cyan - pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68); // yellow + pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff); // cyan + pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68); // yellow // ── Status colors ──────────────────────────────────────────────────── pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e); diff --git a/src/view/workflow.rs b/src/view/workflow.rs index fa7cbcf..58f9a66 100644 --- a/src/view/workflow.rs +++ b/src/view/workflow.rs @@ -7,49 +7,58 @@ //! Design: agents are shown as compact cards with state-colored badges, //! including hive-mind nodes (named by their system-assigned designation, //! e.g. `"Node-0-1"`). - +use super::theme::Theme; +use crate::app::workflow::engine::AgentState; use ratatui::layout::Rect; -use ratatui::style::{Color, Style, Modifier}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; -use super::theme::Theme; -use crate::app::workflow::engine::AgentState; /// Icons for agent states. fn state_icon(state: AgentState) -> &'static str { match state { - AgentState::Idle => "○", - AgentState::Running => "▶", + AgentState::Idle => "○", + AgentState::Running => "▶", AgentState::Completed => "✓", - AgentState::Failed => "✗", + AgentState::Failed => "✗", } } +/// Return the human-readable label for an agent's lifecycle state. fn state_label(state: AgentState) -> &'static str { match state { - AgentState::Idle => "Idle", - AgentState::Running => "Running", + AgentState::Idle => "Idle", + AgentState::Running => "Running", AgentState::Completed => "Done", - AgentState::Failed => "Failed", + AgentState::Failed => "Failed", } } +/// Return the TUI color associated with an agent's lifecycle state. fn state_color(state: AgentState) -> Color { match state { - AgentState::Idle => Theme::TEXT_DIM, - AgentState::Running => Theme::WARNING, + AgentState::Idle => Theme::TEXT_DIM, + AgentState::Running => Theme::WARNING, AgentState::Completed => Theme::SUCCESS, - AgentState::Failed => Theme::ERROR, + AgentState::Failed => Theme::ERROR, } } /// 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}; - let title = Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); + let title = Span::styled( + " Workflow ", + Style::default() + .fg(Theme::PRIMARY) + .add_modifier(Modifier::BOLD), + ); let block = Block::default() .borders(Borders::ALL) @@ -62,29 +71,37 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st // Split inner into header and body let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - ]) + .constraints([Constraint::Length(3), Constraint::Min(4)]) .split(inner); // ── Header area ────────────────────────────────────────────────────── let mut header_lines: Vec = Vec::new(); header_lines.push(Line::from(vec![ - Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled( + "/workflow run ", + Style::default() + .fg(Theme::PRIMARY) + .add_modifier(Modifier::BOLD), + ), Span::styled("", Style::default().fg(Theme::TEXT_DIM)), ])); header_lines.push(Line::from(vec![ Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)), if state.turn_in_flight() { - Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)) + Span::styled( + "● Running", + Style::default() + .fg(Theme::WARNING) + .add_modifier(Modifier::BOLD), + ) } else { Span::styled("● Idle", Style::default().fg(Theme::SUCCESS)) }, Span::raw(" "), Span::styled( - format!("Agents: {} | Findings: {}", + format!( + "Agents: {} | Findings: {}", state.workflow_engine.agents.len(), state.workflow_engine.findings.len(), ), @@ -109,8 +126,8 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st let duration_str = match (agent.status.started_at, agent.status.completed_at) { (Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)), - (Some(_), None) => " (running)".to_string(), - _ => String::new(), + (Some(_), None) => " (running)".to_string(), + _ => String::new(), }; // Agent card header @@ -121,16 +138,12 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st ), Span::styled( format!(" {}", agent.name), - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" [{label}]"), - Style::default().fg(color), - ), - Span::styled( - duration_str, - Style::default().fg(Theme::TEXT_DIM), + Style::default() + .fg(Theme::TEXT) + .add_modifier(Modifier::BOLD), ), + Span::styled(format!(" [{label}]"), Style::default().fg(color)), + Span::styled(duration_str, Style::default().fg(Theme::TEXT_DIM)), ])); // Agent details (progress / error) @@ -143,7 +156,12 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st for line in prog.lines().take(2) { card_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(line.to_string(), Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)), + Span::styled( + line.to_string(), + Style::default() + .fg(Theme::TEXT_DIM) + .add_modifier(Modifier::ITALIC), + ), ])); } } @@ -154,7 +172,12 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st } } -/// Build compact session info for the placeholder view. +/// Build compact session info lines for the placeholder view when no workflow is running. +/// +/// Flow: build lines showing message count, tool call count, pending jobs, +/// and bash jobs from the session runtime, or a "no active session" placeholder. +/// +/// Return: a `Vec` suitable for rendering in the workflow panel body. fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec> { let mut lines: Vec> = Vec::new(); @@ -165,18 +188,26 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec 0 { lines.push(Line::from(vec![ @@ -187,7 +218,10 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec 0 { lines.push(Line::from(vec![ 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 { @@ -200,10 +234,10 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec