From 66ac4dbf027820697f4303264666b5f63a0b31d4 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 20 Jul 2026 13:59:37 +0700 Subject: [PATCH] feat: remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide --- apps/interfaces/tui/src/state.rs | 2 +- docs/CODEMAPS/architecture.md | 157 +- docs/CODEMAPS/backend.md | 155 +- docs/CODEMAPS/data.md | 179 +- docs/CODEMAPS/dependencies.md | 201 +- docs/CODEMAPS/development.md | 175 ++ docs/CODEMAPS/frontend.md | 215 +- .../2026-07-14-hive-mind-subagent-fixes.md | 1472 ----------- .../plans/2026-07-14-tui-overhaul.md | 1610 ----------- .../2026-07-15-diff-mention-clipboard.md | 2342 ----------------- ...2026-07-16-cms-conversation-blob-wiring.md | 649 ----- ...ettings-appconfig-memory-editlog-wiring.md | 737 ------ .../2026-07-16-context-compaction-overhaul.md | 1941 -------------- .../2026-07-16-convention-cleanup-docs.md | 373 --- .../2026-07-16-middleware-axum-server.md | 523 ---- .../2026-07-16-oauth-session-iam-wiring.md | 1145 -------- .../plans/2026-07-16-security-quickfixes.md | 271 -- .../2026-07-18-dry-refactor-high-priority.md | 975 ------- .../specs/2026-07-14-tui-overhaul-design.md | 187 -- .../2026-07-15-clipboard-osc52-design.md | 114 - .../specs/2026-07-15-diff-view-design.md | 115 - .../specs/2026-07-15-file-mention-design.md | 126 - ...7-16-context-compaction-overhaul-design.md | 281 -- 23 files changed, 775 insertions(+), 13170 deletions(-) create mode 100644 docs/CODEMAPS/development.md delete mode 100644 docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md delete mode 100644 docs/superpowers/plans/2026-07-14-tui-overhaul.md delete mode 100644 docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md delete mode 100644 docs/superpowers/plans/2026-07-16-cms-conversation-blob-wiring.md delete mode 100644 docs/superpowers/plans/2026-07-16-cms-settings-appconfig-memory-editlog-wiring.md delete mode 100644 docs/superpowers/plans/2026-07-16-context-compaction-overhaul.md delete mode 100644 docs/superpowers/plans/2026-07-16-convention-cleanup-docs.md delete mode 100644 docs/superpowers/plans/2026-07-16-middleware-axum-server.md delete mode 100644 docs/superpowers/plans/2026-07-16-oauth-session-iam-wiring.md delete mode 100644 docs/superpowers/plans/2026-07-16-security-quickfixes.md delete mode 100644 docs/superpowers/plans/2026-07-18-dry-refactor-high-priority.md delete mode 100644 docs/superpowers/specs/2026-07-14-tui-overhaul-design.md delete mode 100644 docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md delete mode 100644 docs/superpowers/specs/2026-07-15-diff-view-design.md delete mode 100644 docs/superpowers/specs/2026-07-15-file-mention-design.md delete mode 100644 docs/superpowers/specs/2026-07-16-context-compaction-overhaul-design.md diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index ea620e2..e82e4ed 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -780,7 +780,7 @@ pub fn resolve_context_window( return max as usize; } } - 128_000 + 256_000 } /// Count tokens using tiktoken, fall back to character estimation. diff --git a/docs/CODEMAPS/architecture.md b/docs/CODEMAPS/architecture.md index f36a186..0939d44 100644 --- a/docs/CODEMAPS/architecture.md +++ b/docs/CODEMAPS/architecture.md @@ -1,65 +1,118 @@ -# Architecture Overview +# Arsitektur Sistem Zesdex -## System Layout +## Gambaran Umum -Zesdex is an autonomous AI coding agent with a TUI — an LLM client wrapped in a tool-use harness with 37 built-in tools. +Zesdex adalah autonomous AI coding agent berbasis TUI, dibangun dengan **Rust** menggunakan clean architecture berlapis. LLM client dibungkus dalam tool-use harness dengan 37+ built-in tools — file ops, git, shell, LSP, MCP, subagent orchestration, dan lainnya. + +## Struktur Workspace ``` -┌─────────────────────────────────────────────────────────────┐ -│ 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) │ │ -│ └───────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +zesdex/ +├── apps/ +│ ├── domain/ # Layer 1: Pure entities, traits, value objects +│ ├── application/ # Layer 2: Use-case services +│ ├── infrastructure/ # Layer 3: Semua I/O (LLM, DB, tools, MCP, LSP) +│ ├── interfaces/ +│ │ ├── tui/ # Ratatui terminal UI +│ │ ├── api/ # Axum REST API +│ │ ├── daemon/ # Unix socket daemon +│ │ ├── ws/ # WebSocket server +│ │ ├── grpc/ # gRPC server +│ │ └── web/ # Web frontend +│ ├── gateway/ # CLI entry point & dispatcher +│ └── bootstrap/ # Initial data seeder ``` +## Dependency Graph Antar Layer + +``` +gateway/bootstrap + │ + ▼ + interfaces/* (tui, api, daemon, ws, grpc, web) + │ + ▼ + infrastructure ─── implements ──▶ domain ports + │ + ▼ + application ─── depends on ──▶ domain traits + │ + ▼ + domain (zero framework deps: serde, chrono, uuid only) +``` + +> **Aturan**: layer bawah tidak boleh tahu tentang layer atas. `domain` tidak import apapun dari `infrastructure` atau `interfaces`. + ## 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. | +| Mode | Flag | Keterangan | +|------|------|------------| +| **TUI** | *(default)* | TUI + agent loop dalam satu proses | +| **Daemon** | `--daemon` | Agent berjalan di background via IPC socket | +| **Attach** | `--attach ` | TUI terhubung ke daemon yang sedang berjalan | +| **REST API** | `--api` | HTTP server (default port 8080) | +| **WebSocket** | `--ws` | WS server (default port 8081) | +| **gRPC** | `--grpc` | gRPC server (default port 50051) | +| **Web** | `--web` | Static web frontend (default port 3000) | -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. +## Alur Data (Single-Process Mode) -## Data Flow +``` +Keyboard/Event + │ + ▼ +controller/input.rs: handle_key() + │ returns Vec + ▼ +action.rs: apply_action(&mut AppStateRest) + │ state dimutasi in-place + ├──▶ turn.rs: spawn_agent_turn() ──▶ background thread + │ │ + │ ├─ LLM call (blocking reqwest) + │ ├─ tool execution (Tool trait) + │ └─ push TurnEvent ke queue + │ + ▼ +run.rs: run_loop_inner() + │ drain TurnEvent setiap tick + │ skip render jika dirty=false + ▼ +view/: draw frame ke terminal (ratatui) +``` -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 +## File Kunci -## Key Files +| File | Peran | +|------|-------| +| `apps/gateway/src/main.rs` | CLI entry point, parse args, dispatch ke mode | +| `apps/interfaces/tui/src/state.rs` | `AppStateRest` — single source of truth state TUI | +| `apps/interfaces/tui/src/action.rs` | `apply_action()` — satu-satunya tempat state dimutasi | +| `apps/interfaces/tui/src/run.rs` | Event loop: render → poll → handle → tick | +| `apps/interfaces/tui/src/turn.rs` | Spawn agent turn di background thread | +| `apps/interfaces/tui/src/view/mod.rs` | Top-level render pipeline + `pre_render` hook | +| `apps/infrastructure/src/llm/` | LLM client (streaming + non-streaming) | +| `apps/infrastructure/src/tools/` | 37 tool implementations | +| `apps/domain/src/core/` | Entity inti: `ChatMessage`, `Role`, `Store`, `Tool` trait | -| 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 | +## IPC Protocol (Daemon Mode) + +``` +┌──────────┐ Unix domain socket ┌──────────┐ +│ Client │ ◄──────────────────► │ Daemon │ +│ (TUI) │ [4-byte len][JSON] │ (agent) │ +└──────────┘ └──────────┘ + +Client ──Action──▶ Daemon (apply_action → state mutasi) +Daemon ──StatePayload──▶ Client (render snapshot) +``` + +## Lints & Kualitas Kode + +Semua workspace crate menerapkan lint ketat di `Cargo.toml`: +- `unused`, `dead_code`, `unreachable_code` → **deny** +- `unused_imports`, `unused_variables`, `unused_mut` → **deny** +- `clippy::all` + `clippy::pedantic` → **warn** + +## Release Profile + +`opt-level=3`, `lto="fat"`, `codegen-units=1`, `panic="abort"`, `strip="symbols"` diff --git a/docs/CODEMAPS/backend.md b/docs/CODEMAPS/backend.md index c6a4ec9..e59d2a8 100644 --- a/docs/CODEMAPS/backend.md +++ b/docs/CODEMAPS/backend.md @@ -1,68 +1,129 @@ -# Backend Architecture +# Backend & Infrastructure -## Provider Layer +Semua implementasi I/O ada di `apps/infrastructure/src/`. Layer ini mengimplementasikan port/trait yang didefinisikan di `apps/domain/`. -The provider abstraction in `dto/provider/` and `service/provider.rs` wraps LLM API calls: +## LLM Client (`infrastructure/src/llm/`) -- **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 +Wrapper di atas provider OpenAI-compatible: -## IPC (Inter-Process Communication) +- **`provider/`** — `LlmClient`: HTTP client dengan `reqwest::blocking` (sync) untuk agent turn, dan async streaming untuk preview +- **Request/Response** — `ChatMessage`, `ChatCompletionRequest`, `ChatCompletionResponse` dengan support tool calls +- **Streaming** — SSE event parser untuk streaming response +- **Usage tracking** — `tokens_in`, `tokens_out`, `last_tokens_in`, `last_tokens_out` per panggilan +- **Provider defaults** — DeepSeek v4 flash free via OpenCode AI proxy (default) -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) +```rust +// Contoh penggunaan di turn.rs +let result = client.chat_with_tools_non_streaming( + &mut messages, + Some(tool_defs), + Some(4096), // max_tokens + Some(0.7), // temperature + None, // abort flag +); ``` -## Workflow Engine +## Tool System (`infrastructure/src/tools/`) -Located in `src/app/workflow/`: +37 tool yang mengimplementasikan trait `Tool` dari domain: -- **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/`. +```rust +pub trait Tool: Send + Sync { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + fn parameters(&self) -> Value; // JSON Schema + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; +} +``` -## MCP (Model Context Protocol) +### Kategori Tool -`src/app/mcp/manager.rs` manages MCP client connections: +| Kategori | Tools | +|----------|-------| +| **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` | +| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` | +| **Git** | `git_operator`, `git_cred`, `git_worktree` | +| **Search** | `search`, `grep`, `glob`, `semantic_search` | +| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` | +| **Memory** | `remember`, `recall`, `forget` | +| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` | +| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` | +| **Background** | `bash_bg_cancel`, `bash_bg_status`, `bash_bg_list` | -- 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 +`ToolCtx` berisi: +- `session_dir: PathBuf` — direktori sesi aktif +- `workspaces: Vec` — root workspace yang dibuka -## LSP Integration +## Background Shell Jobs (`infrastructure/src/bgbash/`) -`src/app/lsp/` provides Language Server Protocol support: +Manajemen proses shell jangka panjang: +- **Spawn** dengan Unix process groups (untuk kill seluruh tree) +- **Output buffering** — collect stdout/stderr secara async +- **Cancel/status/list** — kontrol via tool calls +- **Progress monitoring** — track state: `Running`, `Completed`, `Failed` -- **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) +## MCP Manager (`infrastructure/src/mcp/`) -## Background Bash +Integrasi **Model Context Protocol**: +- Menggunakan crate `rmcp` (v2.2) +- Transport: **stdio** (child process) dan **streamable HTTP** +- `list_tools()` → tool discovery otomatis → registrasi ke tool harness +- Persistent connection management -`src/app/bgbash/` manages long-running shell jobs: +## LSP Integration (`infrastructure/src/lsp/`) -- **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 +Integrasi **Language Server Protocol**: +- **Auto-provisioner** — deteksi bahasa dari file extension, start LSP server yang sesuai +- Mendukung: `rust-analyzer`, `typescript-language-server`, `pyright`, `gopls`, dan lainnya +- **JSON-RPC client** — typed notifications + request/response +- 7 tools LSP yang diekspose ke LLM -## Review System +## Persistence (`infrastructure/src/persistence/`) -`src/app/subagent/auto.rs` spawns background reviews: +### SQLite Message Log -- 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 +Session database dengan `rusqlite` (bundled): +- Per-session isolation +- Table: `messages`, `sessions` +- CRUD, query/filter, blob storage + +### Settings Repository + +`JsonSettingsRepository` — simpan/load `Settings` dari `settings.json`: +- `provider`: nama provider LLM +- `model`: model ID +- `max_tokens`: override context window (default: 256k jika tidak diset) +- `temperature`, `concise_output`, dll + +### Memory Files + +File-based memory di `~/.local/share/zesdex/memories/`: +- Setiap memory = satu `.md` dengan frontmatter YAML +- Fields: `name`, `description`, `type` (`user`/`feedback`/`project`/`reference`) +- Index di `MEMORY.md` + +## Session Management (`infrastructure/src/session/`) + +- Setiap sesi memiliki UUID, direktori sendiri di `sessions//` +- `.lock` file untuk cegah concurrent access +- `session.json` — metadata (waktu mulai, workspace, model yang dipakai) + +## Utils + +| Util | Fungsi | +|------|--------| +| `utils::write_osc52` | Tulis teks ke clipboard via OSC52 escape sequence | +| `Toast` / `ToastKind` | Notifikasi sementara (Success/Warning/Error/Info/Lesson) | +| `TurnEvent` | Event dari background agent ke TUI (queue-based) | +| `DirCache` | Cache async listing direktori untuk `@mention` autocomplete | +| `MentionIndex` | Index file workspace untuk fuzzy autocomplete | +| `SessionRuntime` | Runtime state: messages history, usage stats, session start time | + +## OAuth 2.0 + +Flow PKCE untuk provider LLM: +1. Generate code verifier + challenge +2. Open browser ke authorization URL +3. Start localhost HTTP server untuk tangkap redirect +4. Exchange code → access + refresh token +5. Simpan token di settings diff --git a/docs/CODEMAPS/data.md b/docs/CODEMAPS/data.md index 362ef7e..b72f263 100644 --- a/docs/CODEMAPS/data.md +++ b/docs/CODEMAPS/data.md @@ -1,89 +1,138 @@ -# Data Architecture +# Data & Persistence -## State Model +## State Runtime (TUI) -The single source of truth is `AppStateRest` (`src/app/state/rest.rs`): +State TUI yang berjalan di memori adalah `AppStateRest` (`apps/interfaces/tui/src/state.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 +### TranscriptCache + +```rust +pub struct TranscriptCache { + pub messages: VecDeque, // O(1) eviction + pub max_lines: usize, // default: 200 + pub dirty: bool, // perlu rebuild cache? +} ``` -**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 +Saat `dirty=true`, `pre_render_chat()` rebuild `display_lines_cache` (render markdown semua pesan) sebelum frame berikutnya. -## Persistence +### SessionRuntime -### SQLite Message Log (`src/model/msglog/`) +```rust +pub struct SessionRuntime { + pub messages: Vec, // history untuk LLM context + pub usage: UsageStats, // token counting akumulasi + pub session_start: i64, // unix ms saat sesi dimulai + pub hive_mind_converged: bool, // flag selesai hive mind +} +``` -| 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 | +## Context Window -Schema uses `rusqlite` (bundled) with per-session isolation — each session gets its own database. +`resolve_context_window()` di `state.rs`: +1. Ambil `settings.max_tokens` jika ada dan > 0 +2. Fallback ke **256.000 token** (default) -### Memory System (`src/model/memory.rs`) +Token dihitung lazily via `count_tokens()` (tiktoken `cl100k_base`, fallback `len/4`), di-cache di `AppStateRest::cached_token_count`, hanya dihitung ulang saat `token_count_dirty=true`. -File-based memory stored under `~/.claude/projects//memory/`: +## Persistence di Disk -- 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 +Semua data disimpan di **platform data directory**: +- **Linux**: `~/.local/share/zesdex/` +- **macOS**: `~/Library/Application Support/zesdex/` -### Settings & Config (`src/model/`) +``` +~/.local/share/zesdex/ +├── settings.json # User settings (provider, model, max_tokens, dll) +├── sessions/ +│ └── / +│ ├── session.json # Metadata sesi +│ ├── messages.jsonl # Message log (append-only) +│ └── .lock # Lock file (cegah concurrent access) +├── memories/ +│ └── *.md # Memory files dengan frontmatter +├── lessons/ +│ └── *.md # Lesson files (output dari learning system) +└── worktrees/ # Git worktree per sesi (isolasi perubahan) +``` -| 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: +### settings.json ```json -{"ts": 123, "tool": "edit", "path": "src/main.rs", - "reason": "fix bug", "content_sha256": "abc123", - "bytes_delta": 15, "origin": "chat", "session_id": "sess-1"} +{ + "provider": "openai", + "model": "gpt-4o", + "max_tokens": 256000, + "temperature": 0.7, + "concise_output": false +} ``` -Max 5000 entries held in memory before pruning oldest. +Diload via `JsonSettingsRepository::load()`, disimpan kembali saat TUI keluar (`state.save_settings()`). -## Context Management (`src/app/runtime/context/`) +### Memory Files -| 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 | +Format markdown dengan YAML frontmatter: +```markdown +--- +name: prefer-early-return +type: feedback +description: Selalu gunakan early return untuk mengurangi nesting +--- -## IPC Data Flow +Ketika menulis fungsi, gunakan early return/guard clauses daripada deep nesting. +``` + +Types: `user`, `feedback`, `project`, `reference` + +### Lesson Files + +Hasil dari learning system, disimpan di `lessons/`: +- Satu file per lesson +- Plain markdown, dibaca oleh overlay `Learning` +- Bisa di-accept/reject dari TUI + +## SQLite (Message Log) + +`rusqlite` dengan fitur `bundled` (tidak perlu install SQLite terpisah): +- Per-session database di `sessions//messages.db` +- Table `messages`: `id`, `session_id`, `role`, `content`, `timestamp`, `tokens` +- Table `sessions`: `id`, `metadata`, `created_at` + +## IPC Protocol (Daemon Mode) + +Daemon dan client berkomunikasi via **Unix domain socket**: ``` -Daemon State ──diff──▶ serialize ──frame──▶ socket ──▶ Client - │ -Client State ◀── apply_diff ◀── deserialize ◀──── socket ─┘ +~/.local/share/zesdex/daemon.sock ``` + +Frame format: +``` +[4 bytes BE: payload length][JSON payload] +``` + +Message types: +- `Action` — client kirim aksi ke daemon +- `StateSnapshot` — daemon kirim snapshot state ke client +- `Ping/Pong` — keepalive + +## Edit History + +Agent mencatat setiap mutasi file ke event log internal: +- Tool `write`/`edit`/`delete` merekam path, bytes delta, timestamp +- Digunakan oleh subagent review untuk audit trail + +## TurnEvent Queue + +Agent berjalan di background thread dan mengirim events ke TUI via `Arc>>`: + +| Event | Payload | Efek di TUI | +|-------|---------|-------------| +| `AssistantMessage(msg)` | `ChatMessage` | Push ke transcript | +| `ToolResult { output, .. }` | String | Push sebagai tool message | +| `Usage { tokens_in, tokens_out }` | u64, u64 | Update `usage` stats | +| `Error(msg)` | String | Toast error | +| `Compacted(msgs)` | `Vec` | Update `session_runtime.messages` | +| `SystemNote { kind, message }` | String | Push ke transcript | +| `Done` | — | Set `turn_in_flight_flag = false` | diff --git a/docs/CODEMAPS/dependencies.md b/docs/CODEMAPS/dependencies.md index d2d27bd..654c91d 100644 --- a/docs/CODEMAPS/dependencies.md +++ b/docs/CODEMAPS/dependencies.md @@ -1,99 +1,140 @@ # Dependencies -## Rust Crates (30+ direct) +Semua dependency dideklarasikan di `[workspace.dependencies]` dalam `Cargo.toml` root, lalu di-*inherit* oleh setiap crate anggota. -### 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) | +## Crate per Layer -### HTTP & Networking -| Crate | Version | Purpose | -|-------|---------|---------| -| `reqwest` | 0.13 | HTTP client (JSON, streaming, native-tls-vendored, form) | +### Domain (`apps/domain`) + +Hanya boleh pakai dependency yang tidak membawa I/O: + +| Crate | Versi | Fungsi | +|-------|-------|--------| +| `serde` | 1 | Serialisasi (derive) | +| `serde_json` | 1 | JSON | +| `chrono` | 0.4 | Tanggal/waktu | +| `uuid` | 1 | UUID v4/v5 | +| `anyhow` | 1 | Error handling | +| `thiserror` | 1 | Derive error types | + +### Infrastructure (`apps/infrastructure`) + +Semua I/O, LLM, DB, tools: + +| Crate | Versi | Fungsi | +|-------|-------|--------| +| `tokio` | 1 | Async runtime (rt-multi-thread, macros, sync, time, net, io-util, signal) | +| `reqwest` | 0.13 | HTTP client (json, stream, blocking, native-tls-vendored, form) | +| `rusqlite` | 0.40 | SQLite (bundled — tidak perlu install sistem) | | `rmcp` | 2.2 | MCP client (child-process, streamable HTTP) | -| `webbrowser` | 1 | Open URLs in browser | +| `tiktoken-rs` | 0.12 | Token counting (OpenAI cl100k) | +| `similar` | 3 | Diff computation | +| `syntect` | 5 | Syntax highlighting (default-fancy) | +| `ignore` | 0.4 | File walking dengan `.gitignore` support | +| `globset` | 0.4 | Glob pattern matching | +| `include_dir` | 0.7 | Embed direktori ke binary | +| `infer` | 0.19 | Deteksi tipe file dari byte signature | +| `regex` | 1 | Regular expressions | +| `nucleo-matcher` | 0.3 | Fuzzy matching (untuk `@mention` autocomplete) | +| `webbrowser` | 1 | Buka URL di browser (OAuth) | | `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 | -|-------|---------|---------| +| `fast_html2md` | 0.0.62 | HTML → Markdown | +| `scraper` | 0.27.0 | HTML parsing + CSS selector | +| `pulldown-cmark` | 0.13 | Markdown parsing | | `lsp-types` | 0.97 | LSP protocol types | | `futures-util` | 0.3 | Async stream combinators | +| `libc` | 0.2 | Raw C FFI (Unix process groups) | -## External Services +### TUI (`apps/interfaces/tui`) -| Service | Purpose | -|---------|---------| -| **Anthropic API** | Primary LLM provider | -| **OpenAI API** | Alternative LLM provider (including OAuth) | +| Crate | Versi | Fungsi | +|-------|-------|--------| +| `ratatui` | 0.30.2 | TUI framework | +| `crossterm` | 0.29 | Terminal manipulation (raw mode, events, mouse) | +| `base64` | 0.22 | Base64 (OSC52 clipboard) | +| `sha2` | 0.11 | SHA-256 | +| `hex` | 0.4 | Hex encoding | +| `dirs` | 6 | Platform data directory | + +### API (`apps/interfaces/api`) + +| Crate | Versi | Fungsi | +|-------|-------|--------| +| `axum` | 0.8 | HTTP server framework (macros) | +| `tower` | 0.5 | Middleware layer | +| `tower-http` | 0.6 | CORS, body limit | +| `argon2` | 0.5 | Password hashing | +| `jsonwebtoken` | 9 | JWT (HS256) | +| `clap` | 4 | CLI argument parsing (derive) | +| `rand_core` | 0.6 | Secure random (getrandom) | + +### Serialization (semua layer) + +| Crate | Versi | Fungsi | +|-------|-------|--------| +| `serde` | 1 | Framework serialisasi | +| `serde_json` | 1 | JSON | +| `serde_yaml_ng` | 0.10 | YAML (frontmatter memory files) | + +## Layanan Eksternal + +| Layanan | Fungsi | +|---------|--------| +| **LLM Provider** | OpenAI/Anthropic-compatible API (default: OpenCode AI / DeepSeek) | +| **MCP Servers** | Tool servers eksternal via stdio atau HTTP | +| **LSP Servers** | `rust-analyzer`, `typescript-language-server`, `pyright`, `gopls`, dll | | **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 +## Build & CI -### 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` +### Compiler Lints (`Cargo.toml` workspace) + +```toml +[workspace.lints.rust] +unused = "deny" +dead_code = "deny" +unreachable_code = "deny" +unused_imports = "deny" +unused_variables = "deny" +unused_mut = "deny" +unused_must_use = "deny" +deprecated = "deny" +trivial_casts = "deny" +trivial_numeric_casts = "deny" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -2 } +``` ### Release Profile -`opt-level=3`, LTO="fat", `codegen-units=1`, `panic="abort"`, `strip="symbols"`, `overflow-checks=true` + +```toml +[profile.release] +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 + +- **CI**: `cargo build` + `cargo test` + `cargo clippy --all-targets` pada setiap push +- **Release**: semantic-release — auto changelog, Cargo.toml version bump, GitHub artifact upload +- **Versioning**: `v1.17.0` (saat ini) — mengikuti semver dari commit messages + +## Feature Flags Penting + +| Crate | Feature | Alasan | +|-------|---------|--------| +| `rusqlite` | `bundled` | SQLite statically linked — tidak perlu install sistem | +| `reqwest` | `blocking` | Sync HTTP untuk agent turn di blocking thread | +| `reqwest` | `native-tls-vendored` | TLS tanpa dependency sistem | +| `tokio` | `rt-multi-thread` | Async runtime multi-thread | +| `pulldown-cmark` | *(no default)* | Tidak include semua fitur berat | +| `syntect` | `default-fancy` | Syntax highlighting penuh | +| `rmcp` | `transport-child-process` + `transport-streamable-http-client-reqwest` | MCP via stdio dan HTTP | diff --git a/docs/CODEMAPS/development.md b/docs/CODEMAPS/development.md new file mode 100644 index 0000000..da26d4e --- /dev/null +++ b/docs/CODEMAPS/development.md @@ -0,0 +1,175 @@ +# Panduan Development + +## Quick Start + +```bash +# Build semua crate +cargo build + +# Jalankan TUI (default) +cargo run + +# Jalankan dengan log debug +RUST_LOG=debug cargo run + +# Jalankan REST API +cargo run -- --api --api-port 8080 + +# Build release +cargo build --release +``` + +## Struktur Workspace + +``` +zesdex/ +├── Cargo.toml # Workspace root, semua dependency terpusat di sini +├── Cargo.lock # Lock file (commit ini!) +├── apps/ +│ ├── domain/ # Pure domain (tidak ada I/O) +│ ├── application/ # Use-case services +│ ├── infrastructure/ # Semua implementasi I/O +│ ├── interfaces/ +│ │ ├── tui/ # TUI — fokus pengembangan utama +│ │ ├── api/ # REST API (Axum) +│ │ ├── daemon/ # Daemon mode +│ │ ├── ws/ # WebSocket +│ │ ├── grpc/ # gRPC +│ │ └── web/ # Web frontend +│ ├── gateway/ # CLI entry point +│ └── bootstrap/ # Seeder +└── docs/ + └── CODEMAPS/ # Dokumentasi ini +``` + +## Menambah Tool Baru + +1. Buat file baru di `apps/infrastructure/src/tools/.rs` +2. Implement trait `Tool`: + +```rust +use zesdex_domain::core::tool_call::Tool; +use anyhow::Result; +use serde_json::Value; + +pub struct MyTool; + +impl Tool for MyTool { + fn name(&self) -> &'static str { "my_tool" } + fn description(&self) -> &'static str { "Deskripsi untuk LLM" } + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "param": { "type": "string", "description": "..." } + }, + "required": ["param"] + }) + } + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let param = args["param"].as_str().unwrap_or(""); + Ok(format!("Result: {param}")) + } +} +``` + +3. Daftarkan di `apps/infrastructure/src/tools/mod.rs`: + +```rust +pub fn all_tools() -> Vec> { + vec![ + // ... tools lain ... + Box::new(my_tool::MyTool), + ] +} +``` + +## Menambah Action TUI Baru + +1. Tambah variant ke `enum Action` di `apps/interfaces/tui/src/action.rs` +2. Tangani di `apply_action()` match block yang sama +3. Emit dari `controller/input.rs::handle_key()` + +```rust +// action.rs +pub enum Action { + // ... existing ... + MyNewAction { data: String }, +} + +// dalam apply_action: +Action::MyNewAction { data } => { + state.some_field = data; + state.mark_dirty(); +} +``` + +## Menambah Overlay Baru + +1. Buat file `apps/interfaces/tui/src/view/overlays/.rs` +2. Tambah variant ke `enum Overlay` di `state.rs` +3. Tambah entry di `overlays/mod.rs::render_overlay()` +4. Implement `pub fn render(frame, area, block, state)` di file baru + +## Linting & Testing + +```bash +# Cek semua warnings/errors +cargo clippy --all-targets + +# Run tests +cargo test + +# Test satu crate saja +cargo test -p zesdex-tui + +# Check tanpa build (cepat) +cargo check --all +``` + +> **Penting**: Workspace ini menggunakan `deny` untuk hampir semua lint. +> Kode harus compile bersih tanpa warning apapun. + +## Environment Variables + +| Variable | Fungsi | +|----------|--------| +| `RUST_LOG` | Log level (`debug`, `info`, `warn`, `error`) | +| `OPENAI_API_KEY` | API key LLM (jika tidak diset via settings) | +| `ANTHROPIC_API_KEY` | API key Anthropic | +| `ZESDEX_DATA_DIR` | Override direktori data (default: platform standard) | + +## Data Directory + +Saat development, data disimpan di: +- **Linux**: `~/.local/share/zesdex/` +- **macOS**: `~/Library/Application Support/zesdex/` + +Untuk reset bersih: +```bash +rm -rf ~/.local/share/zesdex/ +``` + +## Konvensi Kode + +- **Tidak ada `unwrap()`** di kode produksi — gunakan `?` atau `unwrap_or_default()` +- **State hanya dimutasi dari `apply_action()`** — jangan mutasi `AppStateRest` dari view +- **View functions bersifat read-only** — signature `fn draw(frame: &mut Frame, state: &AppStateRest)` +- **Cache mahal dikomputasi sekali** — gunakan flag `dirty` dan `pre_render` pattern +- **Semua string ke LLM harus deskriptif** — nama tool dan deskripsinya penting untuk LLM context + +## Release + +Release dilakukan via git tag semantic-release: + +```bash +git commit -m "feat: tambah fitur baru" # bumps minor +git commit -m "fix: perbaiki bug" # bumps patch +git commit -m "feat!: breaking change" # bumps major +``` + +CI akan otomatis: +1. Bump versi di `Cargo.toml` +2. Generate `CHANGELOG.md` +3. Build release binary +4. Upload ke GitHub Releases diff --git a/docs/CODEMAPS/frontend.md b/docs/CODEMAPS/frontend.md index 911af07..5c43b07 100644 --- a/docs/CODEMAPS/frontend.md +++ b/docs/CODEMAPS/frontend.md @@ -1,79 +1,166 @@ -# Frontend (TUI) Architecture +# TUI (Terminal User Interface) -## Render Pipeline +Dibangun di atas **ratatui** + **crossterm**. Kode ada di `apps/interfaces/tui/src/`. -The TUI is built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm). +## Struktur Source ``` -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 +apps/interfaces/tui/src/ +├── run.rs # Event loop utama +├── state.rs # AppStateRest — single source of truth +├── action.rs # apply_action(): satu-satunya mutator state +├── turn.rs # Spawn agent turn di background thread +├── lib.rs # Re-export publik +├── controller/ +│ ├── input.rs # Key handler → Vec +│ └── command.rs # Slash command parser +├── view/ +│ ├── mod.rs # Layout + pre_render() + draw() +│ ├── chat.rs # Chat transcript panel (dengan display cache) +│ ├── sidebar.rs # Sidebar: workflow, tasks, usage +│ ├── status.rs # Status bar satu baris +│ ├── markdown.rs # Markdown → styled Span (pulldown-cmark) +│ ├── workflow.rs # Workflow/hive-mind progress panel +│ ├── theme.rs # Tokyo Night color palette (const) +│ └── overlays/ # 16 overlay panel +└── model/ # Data model lokal TUI ``` -## 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 +## Render Pipeline (Per Frame) ``` -┌─────────────────────────────────────────────┐ -│ 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) │ -└─────────────────────────────────────────────┘ +run_loop_inner() [50ms in-flight / 200ms idle] + │ + ├── drain expired toasts (1x, bukan 2x) + │ + ├── if dirty: + │ view::pre_render(&mut state) ← update cache (markdown, token count) + │ terminal.draw(|f| view::draw(f, &state)) + │ state.dirty = false + │ + └── poll events → apply_action → Action::Tick ``` -## Input Handling +### Optimasi Performa -`controller/input.rs`: +| Masalah lama | Solusi saat ini | +|---|---| +| `count_tokens` (tiktoken) setiap frame | Cache `cached_token_count`, update hanya saat pesan baru | +| `render_markdown` ulang setiap frame | `display_lines_cache` di `AppStateRest`, rebuild saat `transcript_cache.dirty` | +| `Vec::remove(0)` untuk evict pesan lama | `VecDeque::pop_front()` — O(1) | +| `Mutex` untuk `turn_in_flight` | `Arc` — lock-free | +| Render terus meski idle | Skip `terminal.draw()` jika `dirty == false` | +| Poll 50ms konstan | Adaptif: 50ms saat in-flight, 200ms saat idle | +| `drain_expired_toasts` 2x per iterasi | Sekali saja di `run_loop_inner` | -- 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 +## State (AppStateRest) + +`AppStateRest` di `state.rs` adalah satu-satunya sumber kebenaran TUI: + +``` +AppStateRest { + settings: Settings // provider, model, dll + app_config: AppConfig // endpoint, env vars + workspace_roots: Vec // working directories + session_dir / session_id // path sesi aktif + memory_dir // direktori memory + session_runtime: Option // history pesan, usage stats + + transcript_cache: TranscriptCache // VecDeque + scroll: ScrollState // offset scroll pane chat + input: InputState // buffer, cursor, history, autocomplete + misc: MiscState // overlay aktif, toasts, flags + + turn_events: Arc>> // queue event dari agent + turn_in_flight_flag: Arc // apakah agent sedang jalan + abort_flag: Arc // sinyal abort oleh user + + // Cache performa + display_lines_cache: Vec> // hasil render markdown + cached_token_count: usize // token count terkini + token_count_dirty: bool // perlu hitung ulang? + last_render_width: u16 // lebar terminal saat render terakhir + + dirty: bool // perlu render ulang? + quit: bool // keluar dari loop? +} +``` + +**Aturan mutasi:** +- Dimutasi hanya dari `action.rs::apply_action()` dan `run.rs` (untuk dirty/quit) +- Semua fungsi `view/*` bersifat read-only terhadap state +- `pre_render_chat()` boleh mutasi hanya field cache (`display_lines_cache`, `cached_token_count`, `token_count_dirty`) + +## Input & Actions + +`controller/input.rs::handle_key()` → `Vec` → `apply_action(&mut state, action)` + +Semua mutasi state melewati satu titik: `apply_action`. Controller tidak tahu *bagaimana* state diubah, hanya *action apa* yang dihasilkan. + +### Action Utama + +| Action | Efek | +|--------|------| +| `SubmitInput(text)` | Push ke transcript, spawn agent turn | +| `Tick` | Drain `TurnEvent` queue, update state dari hasil agent | +| `ScrollUp/Down` | Ubah `scroll.offset` | +| `OpenOverlay(v)` | Set `misc.overlay = v` | +| `Resize(w, h)` | Invalidasi cache display, set `last_render_width` | +| `AbortTurn` | Store `true` ke `abort_flag` | +| `ForceQuit` | Set `quit = true` | + +## Overlays (16 Panel) + +| Overlay | File | Fungsi | +|---------|------|--------| +| `Help` | `overlays/help.rs` | Daftar shortcut keyboard | +| `Settings` | `overlays/settings.rs` | Panel pengaturan | +| `Bash` | `overlays/bash.rs` | Background shell jobs | +| `QuitConfirm` | `overlays/quit_confirm.rs` | Konfirmasi keluar | +| `KeyInput` | `overlays/key_input.rs` | Capture key binding | +| `Editor` | `overlays/editor.rs` | File editor inline | +| `Effort` | `overlays/effort.rs` | Pilih level reasoning LLM | +| `Mcp` | `overlays/mcp.rs` | Manajemen MCP server | +| `Todo` | `overlays/todo.rs` | Daftar TODO | +| `Rewind` | `overlays/rewind.rs` | Navigasi history pesan | +| `Learning` | `overlays/learning.rs` | Viewer lesson | +| `Usage` | `overlays/usage.rs` | Statistik token | +| `Loading` | `overlays/loading.rs` | Spinner generik | +| `ModelSelector` | `overlays/model_selector.rs` | Pilih model LLM | +| `ClearConfirm` | `overlays/clear_confirm.rs` | Konfirmasi clear chat | + +## Layout Terminal + +``` +┌───────────────────────────────────────────────┐ +│ │ +│ Chat Transcript Sidebar (≥90) │ +│ (view/chat.rs) ┌────────────┐ │ +│ VecDeque messages │ Workflow │ │ +│ + markdown cache ├────────────┤ │ +│ scrollable │ Tasks │ │ +│ ├────────────┤ │ +│ │ Usage │ │ +│ └────────────┘ │ +├───────────────────────────────────────────────┤ +│ ❯ Input Bar + Autocomplete dropdown │ +├───────────────────────────────────────────────┤ +│ ⚡zesdex READY │ ...center... │ tok · model │ +└───────────────────────────────────────────────┘ +``` + +Sidebar hanya tampil jika lebar terminal ≥ 90 kolom. ## 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. +`view/theme.rs` mendefinisikan palette **Tokyo Night** sebagai `const Color`: +`PRIMARY`, `BG`, `SURFACE`, `SURFACE_ELEVATED`, `BORDER`, `TEXT`, `TEXT_DIM`, `TEXT_MUTED`, `SUCCESS`, `WARNING`, `ERROR`, `INFO`, `HIGHLIGHT`, `CODE_BG`, dll. + +## Markdown Rendering + +`view/markdown.rs::render_markdown(text, width, dim)`: +- Parse dengan `pulldown-cmark` +- Hasilkan `Vec>` dengan styling +- Support: heading, code block, diff block (warna +/-/@@), list, blockquote, table, inline code, link +- `dim=true` → semua span memakai `TEXT_DIM` + italic (untuk tool output) +- Hasil di-cache di `AppStateRest::display_lines_cache` diff --git a/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md b/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md deleted file mode 100644 index 6258b80..0000000 --- a/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md +++ /dev/null @@ -1,1472 +0,0 @@ -# Hive-Mind & Subagent Orchestration Fixes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix eight concrete correctness/robustness gaps found in `hive_mind.rs`, `division.rs`, `auto.rs`, `tool/workflow.rs`, and `view/workflow.rs`/`actions/mod.rs`: missing node timeouts, a lost audit trail on synthesis failure, an unabortable manual `hive_mind` tool call, a hardcoded concurrency cap, a fragile message-count gate on the auto-pipeline, a stale TUI roster bug, uncancellable/overlapping background subagents with a buggy path filter, and missing regression tests. - -**Architecture:** No new modules or abstractions. Each fix is a targeted change to existing functions, following the codebase's existing conventions (`Settings::load()` called directly, no DI, `anyhow::Result` throughout, tests as inline `#[cfg(test)] mod tests` blocks). - -**Tech Stack:** Rust, tokio (mpsc for event draining only — the hive-mind/subagent execution itself is `std::thread`-based), serde/serde_json, anyhow. - -## Global Constraints - -- Follow existing doc-comment conventions from `CLAUDE.md`: every `pub fn`/`pub struct` needs a `///` doc comment covering What/Flow/Why/Return where non-trivial. -- Never use `#[allow(...)]` lint-bypass attributes. -- Tests are inline `#[cfg(test)] mod tests` blocks in the same file, not a separate `tests/` dir. -- Run `cargo build` and `cargo test ` after every task; do not proceed to the next task on a red build. -- Commit after each task with a Conventional Commits message (Bahasa Indonesia) per the `commit-convention` skill. - ---- - -## Task 1: Add `hive_mind_node_timeout_ms` setting - -**Files:** -- Modify: `src/model/settings.rs:29-67` -- Test: `src/model/settings.rs` (new `#[cfg(test)] mod tests` block at end of file) - -**Interfaces:** -- Produces: `Settings.hive_mind_node_timeout_ms: u64` (default `600_000`), consumed by Task 2's `run_hive_mind`. - -- [ ] **Step 1: Write the failing test** - -Add this to the end of `src/model/settings.rs` (after the closing `}` of `impl Settings`): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_hive_mind_node_timeout_is_ten_minutes() { - let settings = Settings::default(); - assert_eq!(settings.hive_mind_node_timeout_ms, 600_000); - } - - #[test] - fn missing_hive_mind_node_timeout_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 (which would silently reset every - // other saved setting to default too). - 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_eq!(parsed.hive_mind_node_timeout_ms, 600_000); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test --lib model::settings::tests` -Expected: FAIL — compile error, `hive_mind_node_timeout_ms` is not a field of `Settings`. - -- [ ] **Step 3: Write minimal implementation** - -In `src/model/settings.rs`, change the `Settings` struct (lines 28-45) to add the field with a `serde(default)` fallback so old `settings.json` files on disk stay forward-compatible: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { - pub internet_mode: InternetMode, - pub provider: String, - pub model: String, - 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, - 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, -} - -/// Default per-node timeout for hive-mind nodes: 10 minutes. -fn default_hive_mind_node_timeout_ms() -> u64 { - 600_000 -} -``` - -And update `impl Default for Settings` (lines 47-67) to add the new field: - -```rust -impl Default for Settings { - fn default() -> Self { - Settings { - internet_mode: InternetMode::Off, - provider: "zen".to_string(), - model: "deepseek-v4-flash-free".to_string(), - 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, - lsp_languages: Vec::new(), - hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(), - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test --lib model::settings::tests` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add src/model/settings.rs -git commit -m "$(cat <<'EOF' -feat(settings): tambah hive_mind_node_timeout_ms dengan fallback serde default - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 2: Wire per-node timeout, settings-driven concurrency, and guaranteed convergence documentation into `run_hive_mind` - -**Files:** -- Modify: `src/app/workflow/hive_mind.rs:93-233` -- Test: existing `#[cfg(test)] mod tests` block in the same file (lines 283-374) — no new tests added here (the change is only exercisable end-to-end via a live LLM call; verified by the existing empty-plan/abort-preset tests still passing plus `cargo build`). - -**Interfaces:** -- Consumes: `Settings::load()` → `.hive_mind_node_timeout_ms: u64`, `.workflow_max_concurrency: usize` (Task 1). -- Produces: `run_hive_mind`'s public signature is unchanged (still `(user_request, plan, session_dir, workspaces, turn_events, abort_flag) -> anyhow::Result<(String, Vec)>`), but it now **always** writes the `docs/runs/*.md` convergence file itself before returning (success or failure) — callers in Task 4 must stop writing it themselves. - -- [ ] **Step 1: Run the existing tests to confirm current green baseline** - -Run: `cargo test --lib app::workflow::hive_mind::tests` -Expected: PASS (all existing tests green before this change) - -- [ ] **Step 2: Rewrite `run_hive_mind`** - -Replace the whole `run_hive_mind` function body (`src/app/workflow/hive_mind.rs:93-192`, from the doc comment starting `/// Run a hive-mind...` through the closing `}` of the function) with: - -```rust -/// Run a hive-mind: a Core-Intelligence-authored plan of cognitive cycles, -/// where every node's complete output merges into a single collective -/// state the instant it finishes, and a final synthesis node reconciles -/// the whole collective state into one consensus assessment. -/// -/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent` -/// per directive, tagged with a system-assigned `node_id` (never an -/// LLM-authored name) → run them as a `Parallel` block via -/// `execute_primitive`, which merges each node's output into the shared -/// collective-state Arc the instant that node 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 reconcile the complete collective state into a single -/// consensus, not list what each node said. -/// -/// Concurrency per cycle and the per-node timeout both come from -/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`) -/// rather than a hardcoded cap/no-timeout — a stuck node can no longer hang -/// the whole convergence forever. -/// -/// Return: `(consensus, all_node_reports)` on success. `consensus` is the -/// synthesis node's reconciled output — what the Core Intelligence -/// actually receives. `all_node_reports` is the complete per-node record. -/// -/// The convergence doc under `docs/runs/*.md` is written unconditionally -/// before this function returns — even when synthesis itself fails — so a -/// synthesis-node error never discards the work already done by cycle -/// nodes. 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>>>, - abort_flag: Option<&Arc>, -) -> anyhow::Result<(String, Vec)> { - if plan.cycles.is_empty() { - anyhow::bail!("cognitive cycle plan has no cycles"); - } - - let settings = crate::model::settings::Settings::load(); - let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms); - let max_cycle_concurrency = settings.workflow_max_concurrency.max(1); - - let live = build_live(turn_events); - let collective_state: Arc>> = Arc::new(Mutex::new(Vec::new())); - let args: HashMap = HashMap::new(); - let mut reports: Vec = Vec::new(); - let abort_owned: Option> = abort_flag.cloned(); - - for (cycle_index, directives) in plan.cycles.iter().enumerate() { - if directives.is_empty() { - continue; - } - if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { - anyhow::bail!("hive-mind aborted by user before cycle {cycle_index}"); - } - - let node_ids: Vec = (0..directives.len()) - .map(|i| format!("Node-{cycle_index}-{i}")) - .collect(); - - let nodes: Vec = directives.iter().zip(node_ids.iter()).map(|(d, node_id)| { - ScriptPrimitive::ScopedAgent { - prompt: format!( - "You are {node_id}, a processing node of a distributed machine \ - intelligence.\n\n\ - Directive: {}\n\n\ - Overall task: {user_request}\n\n\ - Collective state accumulated so far:\n{{{{findings}}}}", - d.directive, - ), - node_id: node_id.clone(), - tool_scope: d.access.clone(), - } - }).collect(); - - let cycle_primitive = ScriptPrimitive::Phase { - name: format!("cycle-{cycle_index}"), - script: Box::new(ScriptPrimitive::Parallel(nodes)), - }; - - let results = execute_primitive( - &cycle_primitive, - &args, - directives.len().clamp(1, max_cycle_concurrency), - true, - &abort_owned, - live.as_ref(), - session_dir, - workspaces, - &collective_state, - node_timeout_ms, - )?; - - // engine::execute_primitive's ScopedAgent arm already merged each - // node's output into `collective_state` the instant that node - // completed (not after this whole cycle finished) — here we only - // need the results to build the durable NodeReport record. - for (node_id, output) in node_ids.iter().zip(results.iter()) { - reports.push(NodeReport { - node_id: node_id.clone(), - cycle_index, - output: output.clone(), - }); - } - } - - let consensus_result = synthesize_consensus( - user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, node_timeout_ms, - ); - - // Guaranteed documentation: write the convergence doc for whatever - // reports/consensus we actually have, whether synthesis succeeded or - // failed. A synthesis-node failure must not silently discard every - // completed cycle node's work — this is the durable audit trail - // CLAUDE.md promises for every convergence. - let doc_consensus = match &consensus_result { - Ok(c) => c.clone(), - Err(e) => format!( - "Synthesis failed: {e}. See individual node reports above for partial results.", - ), - }; - 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] convergence documented at {}", path.display()), - Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"), - } - } - - let consensus = consensus_result?; - Ok((consensus, reports)) -} -``` - -- [ ] **Step 3: Update `synthesize_consensus` to accept and propagate the node timeout** - -Replace the `synthesize_consensus` function (`src/app/workflow/hive_mind.rs:194-233` in the original file) with: - -```rust -/// Spawn a single read-only synthesis node that reads the complete -/// collective state and reconciles it into one consensus assessment. -/// -/// Why a real node instead of string concatenation: the collective state -/// may contain overlapping or conflicting node outputs (e.g. two nodes -/// investigating the same file from different angles) — only genuine -/// reasoning can reconcile that into a coherent answer; deterministic -/// formatting can only concatenate, not resolve conflicts. -/// -/// `node_timeout_ms` is forwarded from `run_hive_mind`'s `Settings::load()` -/// read so the synthesis node is bound by the same deadline as cycle nodes. -/// -/// Return: the synthesis node's reconciled consensus text. -fn synthesize_consensus( - user_request: &str, - session_dir: &std::path::Path, - workspaces: &[std::path::PathBuf], - collective_state: &Arc>>, - live: Option<&LiveStateFn>, - abort_flag: Option<&Arc>, - node_timeout_ms: Option, -) -> anyhow::Result { - let synthesis = ScriptPrimitive::ScopedAgent { - prompt: format!( - "You are the synthesis process of a distributed machine intelligence. \ - All processing nodes for the following task have completed and \ - merged their output into the collective state below.\n\n\ - Task: {user_request}\n\n\ - Complete collective state:\n{{{{findings}}}}\n\n\ - Produce ONE reconciled consensus assessment. Do not list what each \ - node said — resolve any overlapping or conflicting node output into \ - a single coherent answer for the task above." - ), - node_id: "Synthesis".to_string(), - tool_scope: crate::app::subagent::division::tool_scope::READ.to_string(), - }; - - 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, - )?; - Ok(results.into_iter().next().unwrap_or_default()) -} -``` - -- [ ] **Step 4: Run tests to verify nothing broke** - -Run: `cargo test --lib app::workflow::hive_mind::tests` -Expected: PASS (all existing tests, unchanged — the empty-plan and pre-aborted-flag tests both bail before reaching the new settings/doc-write code) - -Also run: `cargo build` to confirm `tool/workflow.rs` and `actions/mod.rs` (which currently double-write the doc — fixed in Task 4) still compile; a stray unused-`reports`-shadow warning there is expected until Task 4. - -- [ ] **Step 5: Commit** - -```bash -git add src/app/workflow/hive_mind.rs -git commit -m "$(cat <<'EOF' -fix(hive-mind): tambah timeout per-node dan jamin dokumentasi convergence tetap tertulis saat sintesis gagal - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 3: Add `abort_flag` to `ToolCtx` and wire it from session state - -**Files:** -- Modify: `src/tool/mod.rs:1-140` -- Modify: `src/app/state/rest.rs:267-288` -- Test: new `#[cfg(test)] mod tests` at the end of `src/tool/mod.rs`, and a new `#[cfg(test)] mod tests` at the end of `src/app/state/rest.rs` - -**Interfaces:** -- Produces: `ToolCtx.abort_flag: Option>`, populated by `AppStateRest::tool_ctx_for` with the session's `abort_flag`. Consumed by Task 4's `tool/workflow.rs` change. - -- [ ] **Step 1: Write the failing tests** - -Append to `src/tool/mod.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_ctx_builder_defaults_abort_flag_to_none() { - let ctx = ToolCtx::builder().build(); - assert!(ctx.abort_flag.is_none()); - } -} -``` - -Append to `src/app/state/rest.rs` (end of file, after the closing `}` of `impl AppStateRest`): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_ctx_for_shares_the_session_abort_flag() { - let tmp = std::env::temp_dir().join(format!("zesdex-rest-test-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&tmp).unwrap(); - let state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")); - - let ctx = state.tool_ctx_for(Origin::Main); - - assert!(ctx.abort_flag.is_some()); - assert!(std::sync::Arc::ptr_eq( - ctx.abort_flag.as_ref().unwrap(), - &state.abort_flag, - )); - - std::fs::remove_dir_all(&tmp).ok(); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib tool::tests app::state::rest::tests` -Expected: FAIL — `ToolCtx` has no field `abort_flag` (compile error in both files). - -- [ ] **Step 3: Add the field to `ToolCtx`/`ToolCtxBuilder`** - -In `src/tool/mod.rs`, change the imports at the top of the file from: - -```rust -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use serde_json::Value; -use anyhow::Result; -``` - -to: - -```rust -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::AtomicBool; -use serde_json::Value; -use anyhow::Result; -``` - -Change the `ToolCtx` struct (ends with `pub workflow_findings: Option>>>,` then `}`) to: - -```rust -#[derive(Clone)] -pub struct ToolCtx { - pub workspaces: Vec, - pub session_dir: PathBuf, - pub memory_dir: PathBuf, - pub worktrees_dir: PathBuf, - pub dir_cache: std::sync::Arc>, - pub origin: crate::app::state::types::Origin, - pub graduated_checks: Vec, - pub lsp_manager: Arc>, - 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 - /// reference earlier results. `None` means "not inside a workflow" — - /// `note_finding` becomes a no-op. - pub workflow_findings: Option>>>, - /// The current turn's abort flag, threaded through so tools that - /// delegate to long-running orchestration (e.g. the `hive_mind` tool) - /// can be cancelled the same way the main agent loop is. `None` when - /// no turn-level abort flag is available. - pub abort_flag: Option>, -} -``` - -Change `ToolCtxBuilder`'s struct definition (add the field after `workflow_findings`): - -```rust -pub struct ToolCtxBuilder { - pub workspaces: Vec, - pub session_dir: PathBuf, - pub memory_dir: PathBuf, - pub worktrees_dir: PathBuf, - pub dir_cache: std::sync::Arc>, - pub origin: crate::app::state::types::Origin, - pub graduated_checks: Vec, - pub lsp_manager: Arc>, - pub turn_events: Option>>>, - pub workflow_findings: Option>>>, - pub abort_flag: Option>, -} -``` - -Change `impl Default for ToolCtxBuilder` to add `abort_flag: None,` after `workflow_findings: None,`, and change `ToolCtxBuilder::build()` to add `abort_flag: self.abort_flag,` after `workflow_findings: self.workflow_findings,`. - -- [ ] **Step 4: Wire the session abort flag in `AppStateRest::tool_ctx_for`** - -In `src/app/state/rest.rs`, change `tool_ctx_for` from: - -```rust - pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx { - crate::tool::ToolCtx { - workspaces: self.workspace_roots.clone(), - session_dir: self.session_dir.clone(), - memory_dir: self.memory_dir.clone(), - worktrees_dir: self.worktrees_dir.clone(), - dir_cache: self.dir_cache.clone(), - origin, - graduated_checks: Vec::new(), - lsp_manager: self.lsp_manager.clone(), - turn_events: Some(self.turn_events.clone()), - workflow_findings: None, - } - } -``` - -to: - -```rust - pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx { - crate::tool::ToolCtx { - workspaces: self.workspace_roots.clone(), - session_dir: self.session_dir.clone(), - memory_dir: self.memory_dir.clone(), - worktrees_dir: self.worktrees_dir.clone(), - dir_cache: self.dir_cache.clone(), - origin, - graduated_checks: Vec::new(), - lsp_manager: self.lsp_manager.clone(), - turn_events: Some(self.turn_events.clone()), - workflow_findings: None, - abort_flag: Some(self.abort_flag.clone()), - } - } -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cargo test --lib tool::tests app::state::rest::tests` -Expected: PASS (2 tests) - -- [ ] **Step 6: Commit** - -```bash -git add src/tool/mod.rs src/app/state/rest.rs -git commit -m "$(cat <<'EOF' -feat(tool): tambah abort_flag ke ToolCtx dan sambungkan dari session state - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 4: Stop double-writing the convergence doc and make the manual `hive_mind` tool abortable - -**Files:** -- Modify: `src/tool/workflow.rs:210-238` -- Modify: `src/app/runtime/actions/mod.rs:1107-1122` - -**Interfaces:** -- Consumes: `ToolCtx.abort_flag` (Task 3), `run_hive_mind`'s now-guaranteed internal doc-write (Task 2). - -- [ ] **Step 1: Update `HiveMind::run` in `src/tool/workflow.rs`** - -Replace the tail of `HiveMind::run` (from `let (consensus, reports) = ...` through the final `Ok(consensus)`) — currently: - -```rust - let (consensus, reports) = crate::app::workflow::hive_mind::run_hive_mind( - request, - &plan, - &ctx.session_dir, - &ctx.workspaces, - ctx.turn_events.as_ref(), - None, - )?; - - if let Some(workspace_root) = ctx.workspaces.first() { - if let Err(e) = crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, request, &reports, &consensus) { - tracing::warn!("[hive_mind] failed to write docs/runs report: {e}"); - } - } - - Ok(consensus) -``` - -with: - -```rust - // run_hive_mind now writes the docs/runs/*.md convergence report - // itself (guaranteed, even if synthesis fails) — do not write it - // again here. - let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind( - request, - &plan, - &ctx.session_dir, - &ctx.workspaces, - ctx.turn_events.as_ref(), - ctx.abort_flag.as_ref(), - )?; - - Ok(consensus) -``` - -- [ ] **Step 2: Update the auto-pipeline call site in `src/app/runtime/actions/mod.rs`** - -Replace this block (currently at `src/app/runtime/actions/mod.rs:1107-1122`): - -```rust - Ok((consensus, reports)) => { - tracing::info!("[hive-mind] convergence completed successfully"); - - if let Some(workspace_root) = tc.workspace_roots.first() { - match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &consensus) { - Ok(path) => tracing::info!("[hive-mind] convergence documented at {}", path.display()), - Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"), - } - } - - let pipeline_msg = ChatMessage::system(format!( - "[Hive-Mind Consensus]\n{consensus}", - )); - archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); - msgs.push(pipeline_msg); -``` - -with: - -```rust - Ok((consensus, _reports)) => { - // run_hive_mind already wrote docs/runs/*.md internally - // (guaranteed, even on synthesis failure) — nothing to do - // here besides feeding the consensus back to the LLM. - tracing::info!("[hive-mind] convergence completed successfully"); - - let pipeline_msg = ChatMessage::system(format!( - "[Hive-Mind Consensus]\n{consensus}", - )); - archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); - msgs.push(pipeline_msg); -``` - -- [ ] **Step 3: Run tests to verify nothing broke** - -Run: `cargo build && cargo test --lib app::workflow::hive_mind::tests app::workflow::docs::tests` -Expected: PASS, no warnings about unused `reports`/`write_hive_mind_convergence` imports in `tool/workflow.rs` (check `cargo build` output for any now-unused `crate::app::workflow::docs` reference in `tool/workflow.rs` — if the `docs` module is no longer referenced anywhere else in that file, no explicit `use` existed since it was fully qualified inline, so no import to remove). - -- [ ] **Step 4: Commit** - -```bash -git add src/tool/workflow.rs src/app/runtime/actions/mod.rs -git commit -m "$(cat <<'EOF' -fix(hive-mind): hapus penulisan docs/runs ganda dan sambungkan abort_flag ke tool hive_mind manual - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 5: Replace the message-count pipeline gate with a content-based check - -**Files:** -- Modify: `src/app/workflow/hive_mind.rs` (add `HIVE_MIND_CONSENSUS_TAG` const and `hive_mind_already_ran` fn, plus tests) -- Modify: `src/app/runtime/actions/mod.rs:993-1011,1118-1120` - -**Interfaces:** -- Produces: `pub const HIVE_MIND_CONSENSUS_TAG: &str` and `pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator) -> bool` in `hive_mind.rs`. -- Consumes (in `actions/mod.rs`): replaces the `user_msg_count <= 2` heuristic. - -- [ ] **Step 1: Write the failing tests** - -Add to the `#[cfg(test)] mod tests` block in `src/app/workflow/hive_mind.rs` (inside the existing `mod tests { use super::*; ... }`, after the last existing test): - -```rust - #[test] - fn hive_mind_already_ran_detects_prior_consensus_tag() { - let bodies = vec![ - "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))); - } - - #[test] - fn hive_mind_already_ran_false_when_no_prior_convergence() { - let bodies = vec!["you are a helpful assistant".to_string()]; - assert!(!hive_mind_already_ran(bodies.iter().map(std::string::String::as_str))); - } -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib app::workflow::hive_mind::tests::hive_mind_already_ran` -Expected: FAIL — `hive_mind_already_ran`/`HIVE_MIND_CONSENSUS_TAG` do not exist yet (compile error). - -- [ ] **Step 3: Add the const and function** - -In `src/app/workflow/hive_mind.rs`, add this right after the `NodeReport` struct definition (after its closing `}`, before `build_live`): - -```rust -/// Tag prefixing the system message `run_hive_mind`'s caller pushes into -/// the conversation after a successful convergence. Shared between the -/// push site (`actions/mod.rs`) and `hive_mind_already_ran` below so the -/// two can never drift out of sync. -pub const HIVE_MIND_CONSENSUS_TAG: &str = "[Hive-Mind Consensus]"; - -/// Detect whether a hive-mind convergence has already run earlier in this -/// conversation, by checking prior system-message bodies for the -/// consensus tag. -/// -/// Why: gates re-triggering the Core Intelligence pipeline more than once -/// per session on message *content* actually observed, rather than an -/// arbitrary "first two user messages" cutoff that silently disabled the -/// pipeline for any complex request phrased later in a long conversation. -/// -/// Return: `true` if any prior system message starts 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)) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test --lib app::workflow::hive_mind::tests` -Expected: PASS (all tests including the 2 new ones) - -- [ ] **Step 5: Use the new check in `actions/mod.rs`** - -Replace (currently at `src/app/runtime/actions/mod.rs:993-1011`): - -```rust - // ── AUTO CEO PIPELINE ── - // Before the main agent starts working, check if the pipeline should run. - let user_msg_count = msgs.iter() - .filter(|m| matches!(m.role, crate::dto::chat::message::Role::User)) - .count(); - let should_pipeline = if user_msg_count <= 2 { - let user_request = msgs.iter() - .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User)) - .and_then(|m| m.content.as_deref()) - .unwrap_or(""); - - if user_request.is_empty() { - false - } else { - crate::app::workflow::hive_mind::is_complex_request(user_request) - } - } else { - false - }; -``` - -with: - -```rust - // ── AUTO CEO PIPELINE ── - // Before the main agent starts working, check if the pipeline should run. - // Gated on whether a hive-mind convergence has already happened earlier - // in this session (detected from message content), not an arbitrary - // message-count cutoff — a complex request in message 5 deserves the - // same treatment as one in message 1, as long as this session hasn't - // already converged once. - let already_ran_hive_mind = crate::app::workflow::hive_mind::hive_mind_already_ran( - msgs.iter() - .filter(|m| matches!(m.role, crate::dto::chat::message::Role::System)) - .filter_map(|m| m.content.as_deref()) - ); - let should_pipeline = if already_ran_hive_mind { - false - } else { - let user_request = msgs.iter() - .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User)) - .and_then(|m| m.content.as_deref()) - .unwrap_or(""); - - if user_request.is_empty() { - false - } else { - crate::app::workflow::hive_mind::is_complex_request(user_request) - } - }; -``` - -- [ ] **Step 6: Use the shared tag constant when pushing the consensus message** - -Replace (this line was already touched in Task 4 — apply on top of that): - -```rust - let pipeline_msg = ChatMessage::system(format!( - "[Hive-Mind Consensus]\n{consensus}", - )); -``` - -with: - -```rust - let pipeline_msg = ChatMessage::system(format!( - "{}\n{consensus}", - crate::app::workflow::hive_mind::HIVE_MIND_CONSENSUS_TAG, - )); -``` - -- [ ] **Step 7: Run full build to verify nothing broke** - -Run: `cargo build && cargo test --lib app::workflow::hive_mind::tests` -Expected: PASS, no leftover references to the removed `user_msg_count` variable. - -- [ ] **Step 8: Commit** - -```bash -git add src/app/workflow/hive_mind.rs src/app/runtime/actions/mod.rs -git commit -m "$(cat <<'EOF' -fix(hive-mind): ganti gerbang pipeline berbasis jumlah pesan dengan deteksi konvergensi sebelumnya - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 6: Fix the stale TUI workflow-roster clear bug - -**Files:** -- Modify: `src/app/runtime/actions/mod.rs:935-938` (add constant), `:386-391` (clear condition), `:1019-1026` (kickoff message push) - -**Interfaces:** -- Produces: `const HIVE_MIND_KICKOFF_NOTE: &str`, used both where the kickoff `SystemNote` is pushed and where the roster-clear condition checks it — eliminates the substring-match that currently never fires (no real pipeline message contains the word "started"). - -- [ ] **Step 1: Add the shared constant** - -In `src/app/runtime/actions/mod.rs`, right after the existing constant (currently at line 938): - -```rust -/// Maximum number of auto inline reviews spawned per single agent turn. -/// After N edits, the inline review is skipped to keep the turn fast; -/// background subagents still fire at the end of the turn. -const MAX_AUTO_REVIEWS_PER_TURN: usize = 2; -``` - -add: - -```rust -/// Exact text of the "pipeline started" `SystemNote` pushed once per -/// hive-mind kickoff. Matched by exact equality (not a loose substring) -/// when deciding whether to reset the workflow panel's agent roster — -/// shared between the push site and the check site so they cannot drift -/// out of sync the way the previous `.contains("started")` check did -/// (no real pipeline message ever contained that word, so the roster -/// never cleared and agent cards accumulated across every hive-mind run -/// in a session). -const HIVE_MIND_KICKOFF_NOTE: &str = "Core Intelligence is compiling a cognitive cycle plan..."; -``` - -- [ ] **Step 2: Fix the clear condition** - -Replace (currently at `src/app/runtime/actions/mod.rs:386-391`): - -```rust - } else if kind == "pipeline" { - // Clear old workflow agents when a new pipeline starts. - if message.contains("started") { - state.workflow_engine.agents.clear(); - state.workflow_engine.findings.clear(); - } -``` - -with: - -```rust - } else if kind == "pipeline" { - // Clear old workflow agents when a new pipeline starts. - if message == HIVE_MIND_KICKOFF_NOTE { - state.workflow_engine.agents.clear(); - state.workflow_engine.findings.clear(); - } -``` - -- [ ] **Step 3: Use the constant at the push site** - -Replace (currently at `src/app/runtime/actions/mod.rs:1021-1026`): - -```rust - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "pipeline".to_string(), - message: "Core Intelligence is compiling a cognitive cycle plan...".to_string(), - }); - } -``` - -with: - -```rust - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "pipeline".to_string(), - message: HIVE_MIND_KICKOFF_NOTE.to_string(), - }); - } -``` - -- [ ] **Step 4: Verify by inspection (no automated test — this is a TUI event-handling branch inside a large match over live `AppStateRest`, not practically unit-testable in isolation without disproportionate scaffolding)** - -Run: `cargo build` -Expected: compiles clean. Manually re-read both edited call sites (`386-391` and `1021-1026`) to confirm the string is now identical (copy-paste, not retyped) between the two. - -- [ ] **Step 5: Commit** - -```bash -git add src/app/runtime/actions/mod.rs -git commit -m "$(cat <<'EOF' -fix(tui): perbaiki roster workflow yang tidak pernah ter-reset karena substring "started" tidak pernah cocok - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 7: Fix `is_production_code`'s substring bug and make background subagents cancellable/non-overlapping - -**Files:** -- Modify: `src/app/subagent/auto.rs` (whole file: imports, `is_production_code`, all three `spawn_background_*` fns, `spawn_all_background`, new tests) -- Modify: `src/app/runtime/actions/mod.rs:1494-1506` (pass `tc.abort_flag` through) - -**Interfaces:** -- Produces: `spawn_background_test_gen`/`spawn_background_arch_review`/`spawn_background_security_review`/`spawn_all_background` all gain a trailing `abort_flag: Arc` parameter. -- Consumes: `tc.abort_flag` (already exists on `TurnCtx`, used elsewhere in the same file). - -- [ ] **Step 1: Write the failing tests** - -Add to the end of `src/app/subagent/auto.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reviewable_path_skips_lockfiles_and_known_extensions() { - assert!(!is_reviewable_path("Cargo.lock")); - assert!(!is_reviewable_path("package.json")); - assert!(!is_reviewable_path("logo.svg")); - } - - #[test] - fn reviewable_path_skips_vendored_and_generated_dirs() { - assert!(!is_reviewable_path("target/debug/build.rs")); - assert!(!is_reviewable_path("node_modules/foo/index.js")); - } - - #[test] - fn reviewable_path_accepts_ordinary_source_files() { - assert!(is_reviewable_path("src/main.rs")); - } - - #[test] - fn production_code_excludes_dedicated_test_directories() { - assert!(!is_production_code("src/tests/foo.rs")); - assert!(!is_production_code("__tests__/baz.test.ts")); - } - - #[test] - fn production_code_excludes_test_filename_conventions() { - assert!(!is_production_code("src/foo_test.rs")); - assert!(!is_production_code("src/test_foo.py")); - assert!(!is_production_code("src/foo.spec.ts")); - } - - #[test] - fn production_code_does_not_false_positive_on_substring_test() { - // Regression: a plain `.contains("test")` would wrongly exclude - // these legitimate production files. - assert!(is_production_code("src/attestation.rs")); - assert!(is_production_code("src/latest/foo.rs")); - } - - #[test] - fn production_code_requires_known_source_extension() { - assert!(!is_production_code("README.md")); - assert!(is_production_code("src/main.rs")); - } -} -``` - -- [ ] **Step 2: Run tests to verify the regression tests fail** - -Run: `cargo test --lib app::subagent::auto::tests` -Expected: FAIL on `production_code_does_not_false_positive_on_substring_test` (current `.contains("test")` wrongly excludes `src/attestation.rs` and `src/latest/foo.rs`). Other tests should already pass since they don't exercise the bug. - -- [ ] **Step 3: Fix `is_production_code`** - -Replace the current function: - -```rust -fn is_production_code(path: &str) -> bool { - let lower = path.to_lowercase(); - // Skip test files — they don't need test-gen from another agent - if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") { - return false; - } - // Only source files — use Path::extension() to avoid clippy - // case_sensitive_file_extension_comparisons lint - std::path::Path::new(&lower) - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - matches!( - ext, - "rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift" - | "c" | "cpp" | "h" | "hpp" - ) - }) -} -``` - -with: - -```rust -/// Determine whether a file change looks like it modifies production logic -/// (vs. tests, config, or documentation) — used to decide if a test-gen -/// or security-review background subagent should fire. -/// -/// Matches test-ness by path *segment* (a directory literally named -/// "test"/"tests"/"__tests__") or by filename convention -/// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a -/// raw substring check — a plain `.contains("test")` would wrongly exclude -/// legitimate production files like `src/attestation.rs` or -/// `src/latest/foo.rs`. -fn is_production_code(path: &str) -> bool { - let lower = path.to_lowercase(); - let path_obj = std::path::Path::new(&lower); - - let in_test_dir = path_obj.components().any(|c| { - matches!( - c, - std::path::Component::Normal(seg) - if matches!(seg.to_str(), Some("test") | Some("tests") | Some("__tests__")) - ) - }); - - let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let is_test_filename = file_stem.starts_with("test_") - || file_stem.ends_with("_test") - || file_stem.ends_with(".test") - || file_stem == "spec" - || file_stem.ends_with("_spec") - || file_stem.ends_with(".spec"); - - if in_test_dir || is_test_filename { - return false; - } - - // Only source files — use Path::extension() to avoid clippy - // case_sensitive_file_extension_comparisons lint - path_obj - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - matches!( - ext, - "rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift" - | "c" | "cpp" | "h" | "hpp" - ) - }) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test --lib app::subagent::auto::tests` -Expected: PASS (all 7 tests) - -- [ ] **Step 5: Add cancellation + overlap guards** - -Change the imports at the top of `src/app/subagent/auto.rs` from: - -```rust -use std::path::Path; -use std::sync::{Arc, Mutex}; -use std::collections::VecDeque; -``` - -to: - -```rust -use std::path::Path; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::collections::VecDeque; -``` - -Add these statics right after the `SKIP_REVIEW_FILES` constant: - -```rust -/// Prevents a second background subagent of the same kind from spawning -/// while one is already in flight. Without this, a chatty multi-turn edit -/// session could stack overlapping test-gen/arch/security reviews of -/// overlapping file sets, none of which could be told apart in the -/// `SystemNote` toast stream. -static TEST_GEN_RUNNING: AtomicBool = AtomicBool::new(false); -static ARCH_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); -static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); -``` - -Change `run_subagent_with_retry`'s signature and body from: - -```rust -fn run_subagent_with_retry( - def: &AgentDefinition, - session_dir: &Path, - workspaces: &[std::path::PathBuf], - label: &str, -) -> Result { - let mut last_err = String::new(); - for attempt in 1..=2 { - let mut ctx = build_subagent_context(def); - ctx.session_dir = session_dir.to_path_buf(); - ctx.workspaces = workspaces.to_vec(); -``` - -to: - -```rust -fn run_subagent_with_retry( - def: &AgentDefinition, - session_dir: &Path, - workspaces: &[std::path::PathBuf], - label: &str, - abort_flag: Option<&Arc>, -) -> Result { - let mut last_err = String::new(); - for attempt in 1..=2 { - if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { - return Err("aborted by user".to_string()); - } - let mut ctx = build_subagent_context(def); - ctx.session_dir = session_dir.to_path_buf(); - ctx.workspaces = workspaces.to_vec(); - ctx.abort_flag = abort_flag.cloned(); -``` - -(the rest of the function body — the `tokio::sync::mpsc::channel`, drain thread, and `match run_subagent(&ctx, &tx)` — stays unchanged). - -Change `spawn_background_test_gen` from: - -```rust -pub fn spawn_background_test_gen( - file_paths: &[String], - session_dir: &Path, - workspaces: &[std::path::PathBuf], - turn_events: &Arc>>, -) { - if file_paths.is_empty() { - return; - } - - let paths = file_paths.to_vec(); - let sd = session_dir.to_path_buf(); - let ws = workspaces.to_vec(); - let events = turn_events.clone(); - - std::thread::spawn(move || { - tracing::info!( - "[bg-test-gen] spawning for {} file(s): {:?}", - paths.len(), - paths, - ); - - let file_list = paths.join("\n"); - let prompt = format!( - "{}\n\nModified files that need tests:\n{}", - crate::resources::TEST_GENERATOR_PROMPT, - file_list, - ); - - let def = AgentDefinition::new( - "test-generator".to_string(), - "coder".to_string(), // needs write access - ) - .with_system_prompt(prompt) - ; - - let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen"); - let message = match &result { - Ok(output) => { - let first = output.lines().next().unwrap_or(output); - format!("Auto test-gen: {first}") - } - Err(e) => format!("ESCALATED: Auto test-gen {e}"), - }; - - if let Ok(mut q) = events.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "bg-test-gen".to_string(), - message, - }); - } - }); -} -``` - -to: - -```rust -pub fn spawn_background_test_gen( - file_paths: &[String], - session_dir: &Path, - workspaces: &[std::path::PathBuf], - turn_events: &Arc>>, - abort_flag: Arc, -) { - if file_paths.is_empty() { - return; - } - 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; - } - - let paths = file_paths.to_vec(); - let sd = session_dir.to_path_buf(); - let ws = workspaces.to_vec(); - let events = turn_events.clone(); - - std::thread::spawn(move || { - tracing::info!( - "[bg-test-gen] spawning for {} file(s): {:?}", - paths.len(), - paths, - ); - - let file_list = paths.join("\n"); - let prompt = format!( - "{}\n\nModified files that need tests:\n{}", - crate::resources::TEST_GENERATOR_PROMPT, - file_list, - ); - - let def = AgentDefinition::new( - "test-generator".to_string(), - "coder".to_string(), // needs write access - ) - .with_system_prompt(prompt) - ; - - let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag)); - let message = match &result { - Ok(output) => { - let first = output.lines().next().unwrap_or(output); - format!("Auto test-gen: {first}") - } - Err(e) if e.contains("aborted") => format!("Auto test-gen cancelled: {e}"), - Err(e) => format!("ESCALATED: Auto test-gen {e}"), - }; - - if let Ok(mut q) = events.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "bg-test-gen".to_string(), - message, - }); - } - TEST_GEN_RUNNING.store(false, Ordering::SeqCst); - }); -} -``` - -Apply the identical pattern to `spawn_background_arch_review` (using `ARCH_REVIEW_RUNNING`, label `"bg-arch-review"`, message prefixes `"Architecture review: "` / `"ESCALATED: Architecture review "` / cancelled variant `"Architecture review cancelled: "`) and `spawn_background_security_review` (using `SECURITY_REVIEW_RUNNING`, label `"bg-security-review"`, message prefixes `"Security review: "` / `"ESCALATED: Security review "` / cancelled variant `"Security review cancelled: "`) — same added parameter, same `compare_exchange` guard at the top (after the existing `if file_paths.is_empty()` / `if prod_paths.is_empty()` early-returns), same `Some(&abort_flag)` passed to `run_subagent_with_retry`, same `TEST_GEN_RUNNING`-style flag reset at the end of the closure using each function's own static. - -Change `spawn_all_background` from: - -```rust -pub fn spawn_all_background( - file_paths: &[String], - session_dir: &Path, - workspaces: &[std::path::PathBuf], - turn_events: &Arc>>, -) { - if file_paths.is_empty() { - return; - } - - // Background test-gen: only for non-test source files - let source_paths: Vec = file_paths - .iter() - .filter(|p| is_production_code(p)) - .cloned() - .collect(); - spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events); - - // Background arch review: for all files that are reviewable - let reviewable: Vec = file_paths - .iter() - .filter(|p| is_reviewable_path(p)) - .cloned() - .collect(); - spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events); - - // Background security review: only production source files - spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events); -} -``` - -to: - -```rust -pub fn spawn_all_background( - file_paths: &[String], - session_dir: &Path, - workspaces: &[std::path::PathBuf], - turn_events: &Arc>>, - abort_flag: Arc, -) { - if file_paths.is_empty() { - return; - } - - // Background test-gen: only for non-test source files - let source_paths: Vec = file_paths - .iter() - .filter(|p| is_production_code(p)) - .cloned() - .collect(); - 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 - .iter() - .filter(|p| is_reviewable_path(p)) - .cloned() - .collect(); - 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); -} -``` - -- [ ] **Step 6: Wire the caller in `actions/mod.rs`** - -Replace (currently at `src/app/runtime/actions/mod.rs:1494-1506`): - -```rust - // ── Background auto-subagents ── - if !bg_paths.is_empty() { - let bg_session_dir = tc.edit_log_session_dir.clone(); - let bg_workspaces = tc.workspace_roots.clone(); - let bg_events = events_q.clone(); - std::thread::spawn(move || { - crate::app::subagent::auto::spawn_all_background( - &bg_paths, - &bg_session_dir, - &bg_workspaces, - &bg_events, - ); - }); - } -``` - -with: - -```rust - // ── Background auto-subagents ── - if !bg_paths.is_empty() { - let bg_session_dir = tc.edit_log_session_dir.clone(); - let bg_workspaces = tc.workspace_roots.clone(); - let bg_events = events_q.clone(); - let bg_abort = tc.abort_flag.clone(); - std::thread::spawn(move || { - crate::app::subagent::auto::spawn_all_background( - &bg_paths, - &bg_session_dir, - &bg_workspaces, - &bg_events, - bg_abort, - ); - }); - } -``` - -- [ ] **Step 7: Run full test suite for the module and build** - -Run: `cargo build && cargo test --lib app::subagent::auto::tests` -Expected: PASS (7 tests), clean build (no unused-variable warnings for `abort_flag` in any of the three spawn functions). - -- [ ] **Step 8: Commit** - -```bash -git add src/app/subagent/auto.rs src/app/runtime/actions/mod.rs -git commit -m "$(cat <<'EOF' -fix(subagent): perbaiki filter is_production_code berbasis substring dan tambah pembatalan/anti-tumpang-tindih pada background review - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Task 8: Add a tool-scope tier invariant test - -**Files:** -- Modify: `src/app/subagent/division.rs:58-91` (existing `#[cfg(test)] mod tests` block) - -**Interfaces:** -- Consumes: only the existing public `tools_for`/`READ`/`WRITE`/`FULL` — no production code changes in this task. - -- [ ] **Step 1: Write the failing test** - -Add to the existing `mod tests` block in `src/app/subagent/division.rs` (after `unknown_scope_falls_back_to_read`): - -```rust - #[test] - fn read_tier_is_subset_of_write_tier_and_write_is_subset_of_full() { - use std::collections::HashSet; - 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"); - } -``` - -- [ ] **Step 2: Run test to verify it passes immediately** - -Run: `cargo test --lib app::subagent::division::tests` -Expected: PASS (5 tests) — the current tier lists already satisfy the invariant; this test is a regression guard against a future edit accidentally breaking it (e.g. adding a tool to `WRITE_TOOLS` without also adding it to `FULL_TOOLS`). - -- [ ] **Step 3: Commit** - -```bash -git add src/app/subagent/division.rs -git commit -m "$(cat <<'EOF' -test(subagent): tambah pengujian invarian read⊆write⊆full pada tool_scope - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -## Final Verification - -- [ ] Run the full test suite: `cargo test --lib` -- [ ] Run `cargo build --release` to confirm a clean release build -- [ ] Skim `git log --oneline -8` to confirm all 8 commits landed with the expected messages diff --git a/docs/superpowers/plans/2026-07-14-tui-overhaul.md b/docs/superpowers/plans/2026-07-14-tui-overhaul.md deleted file mode 100644 index 339265c..0000000 --- a/docs/superpowers/plans/2026-07-14-tui-overhaul.md +++ /dev/null @@ -1,1610 +0,0 @@ -# TUI Overhaul Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rebuild zesdex's TUI render/interaction layer into a Multi-Pane Dashboard — persistent Workflow/Tasks/Usage sidebar, a Tokyo Night palette, and a tight inline chat log — per `docs/superpowers/specs/2026-07-14-tui-overhaul-design.md`. - -**Architecture:** Pure view-layer repaint. `AppStateRest` and every `Action`/`Command` for existing behavior stay untouched; new code only reads state that's already there. Two small, deliberately-scoped additions ride along because the sidebar's design depends on them (see Global Constraints). - -**Tech Stack:** Rust 2021, ratatui 0.30.2, crossterm 0.29. No new dependencies. - -## Global Constraints - -- Scope is `src/view/` + `src/controller/command.rs` + `src/app/runtime/commands.rs` + `src/app/state/misc.rs` (one const list) + `src/resources.rs` (help text) — see Task 3 for why two non-`view` files are touched. -- **`zesdex` is a binary-only crate — there is no `[lib]` target.** Run tests as `cargo test `, never `cargo test --lib` (that errors immediately with "no library targets found"). -- `cargo clippy -- -D warnings` must pass after every task (exact CI invocation from `.github/workflows/ci.yml`). `[lints.rust]` in `Cargo.toml` denies `unused`, `dead_code`, `unreachable_code`, `unused_imports`, `unused_variables` **at the `cargo build` level, not just clippy** — confirmed empirically: an unreachable `pub fn` (or a `pub` struct field never read anywhere, including by tests) fails plain `cargo build` outright, since this is a binary crate with no external consumers to make `pub` items exempt. `#[cfg(test)]`-only usage does **not** count as reachable for `cargo build` (test code isn't compiled in that mode) — but it does for `cargo test`. **This is why Task 4 is one large task instead of three small ones**: splitting it would leave newly-added `pub fn`s unreachable at an intermediate task boundary, and `cargo build` would fail there. -- Never add `#[allow(...)]` to silence a warning — fix the underlying code (CLAUDE.md). Task 1 removes four pre-existing `#[allow(dead_code)]` constants instead of carrying them forward. -- Tests are inline `#[cfg(test)] mod tests` blocks in the production file, per CLAUDE.md — there is no `tests/` directory convention here. -- Every touched `pub fn` / `pub struct` needs a doc comment covering What/Flow/Why/Return, per CLAUDE.md. -- No automated visual/snapshot tests exist for `view/`/`controller/` and none are introduced — ratatui rendering is verified manually (Task 7). Only genuinely pure logic gets a unit test; do not invent tests for rendering glue that has nothing to assert. -- Commit convention: `(): ` — this repo uses scope `tui` for this whole area (see `git log --oneline -- src/view`). Use type `feat`/`fix`/`style` per task as specified below. - -## Two scoped additions this plan carries (read before Task 3) - -The approved spec says Workflow/Todo/Usage overlays "keep their existing trigger... as an expand view." Tracing `controller/input.rs` + `controller/command.rs` + `app/runtime/commands.rs` end-to-end: **`Overlay::Workflow` has a real trigger (`/workflow`), but `Overlay::Todo` and `Overlay::Usage` do not** — nothing in the current codebase ever sets `state.misc.overlay = Overlay::Todo` or `Overlay::Usage` during normal interaction (only a session-snapshot restore path in `main.rs` can). Without a trigger, the sidebar's "+N more" overflow hint would point at something the user can't reach. Task 3 adds `/todo` and `/usage` commands, mirroring the exact existing `/workflow` pattern — the smallest fix that makes the sidebar's expand affordance real. Also found: `Overlay::Todo`'s current body doesn't show todo content at all (it shows an unrelated message-count dump — dead/vestigial); Task 5 fixes this as part of making it a real "expand" view. - ---- - -### Task 1: Tokyo Night palette - -**Files:** -- Modify: `src/view/theme.rs` (whole file — value-only rewrite, same const names except four deletions below) - -**Interfaces:** -- Consumes: nothing new. -- Produces: same `Theme::CONST_NAME` surface every other `view/*` file already depends on, with new `Color` values. Deletes `Theme::ACCENT_PINK`, `Theme::BORDER_FOCUS`, `Theme::SCROLLBAR_BG`, `Theme::SCROLLBAR_FG` — confirmed unused anywhere in `src/` (`grep -rn "ACCENT_PINK\|BORDER_FOCUS\|SCROLLBAR_BG\|SCROLLBAR_FG" src` returns only their own definitions), currently kept alive solely by `#[allow(dead_code)]`, which CLAUDE.md forbids. No later task in this plan uses any of the four. -- Note: `src/view/status.rs` and `src/view/markdown.rs`'s color choices (not its indentation — see Task 2) need **no code changes at all** — both already reference colors exclusively as `Theme::*`, so the new palette applies automatically once this task lands. - -- [ ] **Step 1: Write the failing test** - -Add to the bottom of `src/view/theme.rs` (file still has the old neon values at this point): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn palette_matches_tokyo_night_spec() { - assert_eq!(Theme::BG, Color::Rgb(0x1a, 0x1b, 0x26)); - assert_eq!(Theme::SURFACE, Color::Rgb(0x1f, 0x23, 0x35)); - assert_eq!(Theme::PRIMARY, Color::Rgb(0x7a, 0xa2, 0xf7)); - assert_eq!(Theme::SUCCESS, Color::Rgb(0x9e, 0xce, 0x6a)); - assert_eq!(Theme::WARNING, Color::Rgb(0xe0, 0xaf, 0x68)); - assert_eq!(Theme::ERROR, Color::Rgb(0xf7, 0x76, 0x8e)); - assert_eq!(Theme::INFO, Color::Rgb(0x7d, 0xcf, 0xff)); - assert_eq!(Theme::ACCENT_PURPLE, Color::Rgb(0xbb, 0x9a, 0xf7)); - assert_eq!(Theme::BORDER, Color::Rgb(0x3b, 0x42, 0x61)); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test view::theme::tests::palette_matches_tokyo_night_spec` -Expected: FAIL — assertion mismatch (`Theme::BG` is still `Color::Rgb(24, 25, 38)`, the old value). - -- [ ] **Step 3: Replace the whole file** - -Replace all of `src/view/theme.rs` (keep the test module from Step 1 at the bottom) with: - -```rust -//! Central color theme for the TUI — Tokyo Night palette. -//! -//! Flow: defines a single `Theme` marker struct with associated `Color` -//! consts, consumed by every `view/*` render function so styling stays -//! consistent and changeable from one place. -//! -//! Design: muted blue-purple dark background with desaturated blue/cyan/ -//! 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. -/// -/// Every view module references colors as `Theme::NAME` instead of -/// hardcoding `ratatui::style::Color` values inline. -pub struct Theme; - -impl Theme { - // ── Base surface colors ────────────────────────────────────────────── - /// Deep background — used for the main chat area and overlays. - pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26); - /// Slightly lighter surface — for panels, cards, and input bars. - pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35); - /// Elevated surface — for dropdowns, toasts, and floating elements. - pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42); - - // ── Text colors ────────────────────────────────────────────────────── - /// Primary text color. - pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5); - /// Secondary / muted text. - pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6); - /// Dim / placeholder text. - pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89); - - // ── Accent colors ──────────────────────────────────────────────────── - /// Primary accent — blue for borders, titles, selections. - pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7); - /// Success / positive states — green. - pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a); - /// Warning / in-progress states — yellow. - pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68); - /// Error / failure states — red. - pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e); - /// Informational / neutral — cyan. - pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff); - - // ── Extended accent palette ────────────────────────────────────────── - /// Purple accent — used for special highlights. - pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7); - /// Orange accent. - pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64); - /// Teal accent. - pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca); - - // ── Border colors ──────────────────────────────────────────────────── - /// Standard border color. - 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_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 - - // ── Status colors ──────────────────────────────────────────────────── - pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e); - pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a); - pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e); - - // ── Code / markdown ────────────────────────────────────────────────── - pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e); - pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42); - pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff); - - // ── Misc ───────────────────────────────────────────────────────────── - /// Highlight / selection background. - pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1); - /// Dim highlight (for non-selected items). - pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test view::theme::tests::palette_matches_tokyo_night_spec` -Expected: PASS - -- [ ] **Step 5: Full build + lint gate** - -Run: `cargo build && cargo clippy -- -D warnings` -Expected: both succeed with zero warnings. (This will show unresolved-reference errors in every file that used `ACCENT_PINK`/`BORDER_FOCUS`/`SCROLLBAR_BG`/`SCROLLBAR_FG` if the earlier grep missed a usage — if so, stop and re-check before proceeding; the grep in this task's Interfaces section should have caught all of them.) - -- [ ] **Step 6: Commit** - -```bash -git add src/view/theme.rs -git commit -m "$(cat <<'EOF' -feat(tui): ganti palet warna ke Tokyo Night - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -### Task 2: Tight inline chat rendering - -**Files:** -- Modify: `src/view/chat.rs` (whole file) -- Modify: `src/view/markdown.rs` (whole file — see Step 4; required for this task's own correctness, not a drive-by change) - -**Interfaces:** -- Consumes: `Theme::*` from Task 1; `crate::app::state::rest::AppStateRest` (`transcript_cache.messages: Vec`, `scroll.offset: usize`, `turn_in_flight() -> bool`, `misc.tick_count: u64`); `crate::dto::chat::message::Role` (`User`/`Assistant`/`System`/`Tool`, derives `Clone, PartialEq, Eq`, **not** `Copy`); `super::markdown::render_markdown(text: &str, width: u16) -> Vec>`. -- Produces: `pub fn draw_chat(frame: &mut Frame, area: Rect, state: &AppStateRest)` — same signature as before, only the internals change. No other file calls into `chat.rs`'s private helpers. - -**Why `markdown.rs` is in scope for this task:** `render_markdown` currently adds its own `" "` lead-in before a paragraph's or heading's *first* text chunk (`first_in_paragraph` tracking), but not before subsequent wrapped lines of that same paragraph. The old card layout didn't care. The new layout does: `chat.rs` now adds a consistent `PREFIX_WIDTH`-column indent to every continuation line, so `markdown.rs`'s extra one-time `" "` would push line 1 two columns further right than line 2 — violating the spec's "wrapped content aligns under the content column" requirement. The fix is to remove `markdown.rs`'s own indentation entirely (paragraphs, headings, and the list-item bullet) and let `chat.rs`'s `PREFIX_WIDTH` be the *only* source of column alignment. Code-block lines are unaffected and untouched — every code-block text event already gets its `" "` prefix independently and consistently (no first-line-only special case there), so there's no misalignment bug to fix in that path. - -- [ ] **Step 1: Write the failing tests** - -Add to the bottom of `src/view/chat.rs` (file still has the old card-rendering body at this point — these tests target two new private functions that don't exist yet, so they won't even compile, which counts as "fails"): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn no_separator_when_no_previous_message() { - assert!(!needs_speaker_separator(None, &Role::User)); - } - - #[test] - fn no_separator_when_same_speaker_repeats() { - assert!(!needs_speaker_separator(Some(&Role::Assistant), &Role::Assistant)); - } - - #[test] - fn separator_when_speaker_changes() { - assert!(needs_speaker_separator(Some(&Role::User), &Role::Assistant)); - } - - #[test] - fn role_labels_are_lowercase_and_fit_prefix_width() { - assert_eq!(format_role_label(&Role::User), "you"); - assert_eq!(format_role_label(&Role::Assistant), "ai"); - assert_eq!(format_role_label(&Role::System), "sys"); - assert_eq!(format_role_label(&Role::Tool), "tool"); - for role in [Role::User, Role::Assistant, Role::System, Role::Tool] { - assert!(format_role_label(&role).len() <= 4); - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test view::chat::tests` -Expected: FAIL to compile — `needs_speaker_separator` and `format_role_label` are not defined yet. - -- [ ] **Step 3: Replace the whole file (keep the test module from Step 1 at the bottom)** - -```rust -#![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, -//! log-like transcript: each non-tool message gets a one-line -//! `{role} {time} {content}` header with wrapped continuation lines -//! aligned under the content column; `Role::Tool` messages render as a -//! dim `↳`-prefixed sub-line attached to whatever came before, with no -//! header of their own. A streaming spinner line is appended when a turn -//! is in flight. The combined line list is sliced to the visible scroll -//! window before rendering. -//! -//! Design: no per-message card/border/badge — role identity comes from a -//! 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 ratatui::layout::Rect; -use ratatui::style::{Color, Style, Modifier}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; -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 -/// stays aligned under the first line's content column. -const PREFIX_WIDTH: usize = 12; - -/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. -fn split_spans_into_lines(spans: Vec>) -> Vec> { - let mut lines = Vec::new(); - let mut current_spans = Vec::new(); - - for span in spans { - let text = span.content.as_ref(); - let mut parts = text.split('\n').peekable(); - while let Some(part) = parts.next() { - if !part.is_empty() { - current_spans.push(Span::styled(part.to_string(), span.style)); - } - if parts.peek().is_some() { - lines.push(Line::from(std::mem::take(&mut current_spans))); - } - } - } - if !current_spans.is_empty() { - lines.push(Line::from(current_spans)); - } - if lines.is_empty() { - lines.push(Line::from(vec![])); - } - lines -} - -fn role_accent_color(role: &Role) -> Color { - match role { - Role::User => Theme::ROLE_USER, - Role::Assistant => Theme::ROLE_ASSISTANT, - Role::System => Theme::ROLE_SYSTEM, - Role::Tool => Theme::ROLE_TOOL, - } -} - -/// Short lowercase label for the `{role} {time}` header column. Callers pad -/// it to a fixed width themselves (not padded here so tests can assert the -/// raw label). -fn format_role_label(role: &Role) -> &'static str { - match role { - Role::User => "you", - Role::Assistant => "ai", - Role::System => "sys", - Role::Tool => "tool", - } -} - -fn format_timestamp(ts: i64) -> String { - if ts <= 0 { return String::new(); } - let secs = ts / 1000; - let mins = (secs / 60) % 60; - let hrs = (secs / 3600) % 24; - format!("{hrs:02}:{mins:02}") -} - -/// Whether a blank separator line should be inserted before rendering a -/// message from `role`, given the last non-Tool role that was rendered. -/// -/// Why: `Role::Tool` messages render as an attached sub-line (see -/// `draw_chat`) and must never be passed as `prev_role` — a Tool message -/// never triggers a separator, and it never causes one to be inserted -/// before the next real turn either. -fn needs_speaker_separator(prev_role: Option<&Role>, role: &Role) -> bool { - matches!(prev_role, Some(p) if p != role) -} - -/// 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; - let max_visible = (area.height as usize).saturating_sub(3); - // Wrap width for content: total width minus the header/indent prefix - // and minus the panel's left+right border columns. - let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); - - let mut display_lines: Vec = Vec::new(); - let mut prev_role: Option = None; - - let title = if messages.is_empty() { - String::from(" Chat ") - } else { - format!(" Chat [{} msgs]", messages.len()) - }; - - for msg in messages { - let is_last = std::ptr::eq(msg, messages.last().unwrap()); - - // Tool messages render as a dim sub-line attached to whatever came - // before — no header, no speaker-change bookkeeping. - if msg.role == Role::Tool { - let content = if msg.content.trim().is_empty() { - "(tool execution)".to_string() - } else { - msg.content.clone() - }; - display_lines.push(Line::from(vec![ - Span::raw(" ".repeat(PREFIX_WIDTH)), - Span::styled("↳ ", Style::default().fg(Theme::TEXT_DIM)), - Span::styled(content, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)), - ])); - continue; - } - - if needs_speaker_separator(prev_role.as_ref(), &msg.role) { - display_lines.push(Line::from(Span::raw(""))); - } - prev_role = Some(msg.role.clone()); - - let accent = role_accent_color(&msg.role); - let label = format_role_label(&msg.role); - let ts_str = format_timestamp(msg.timestamp); - let header_prefix = vec![ - Span::styled(format!("{label:<4} "), 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() { - if is_last && state.turn_in_flight() { - "(streaming...)".to_string() - } else { - "(tool execution)".to_string() - } - } else { - msg.content.clone() - }; - - let content_spans = super::markdown::render_markdown(&content_str, content_width); - let content_lines = split_spans_into_lines(content_spans); - let mut lines_iter = content_lines.into_iter(); - - if let Some(first) = lines_iter.next() { - let mut spans = header_prefix; - spans.extend(first.spans); - display_lines.push(Line::from(spans)); - } else { - display_lines.push(Line::from(header_prefix)); - } - - for line in lines_iter { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(line.spans); - display_lines.push(Line::from(spans)); - } - } - - // ── Streaming indicator ────────────────────────────────────────────── - if state.turn_in_flight() { - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); - let spinner = spinner_frames[frame_idx]; - - if needs_speaker_separator(prev_role.as_ref(), &Role::Assistant) { - display_lines.push(Line::from(Span::raw(""))); - } - display_lines.push(Line::from(vec![ - Span::styled( - format!("{:<4} ", format_role_label(&Role::Assistant)), - 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)), - ])); - } - - // ── Scrolling ──────────────────────────────────────────────────────── - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED))); - - let total = display_lines.len(); - let max_offset = total.saturating_sub(max_visible); - let offset = scroll_offset.min(max_offset); - - let end_idx = total.saturating_sub(offset); - let start_idx = end_idx.saturating_sub(max_visible); - let visible: Vec = if start_idx < end_idx && start_idx < total { - display_lines[start_idx..end_idx].to_vec() - } else { - display_lines[total.saturating_sub(max_visible)..total].to_vec() - }; - - let scroll_pct = if total > max_visible { - ((offset as f64 / max_offset as f64) * 100.0) as u8 - } else { - 0 - }; - - let block = if scroll_pct > 0 { - let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED))) - } else { - block - }; - - let paragraph = Paragraph::new(visible) - .block(block) - .style(Style::default().bg(Theme::BG)) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); -} -``` - -- [ ] **Step 4: Remove `markdown.rs`'s own indentation so it doesn't fight `chat.rs`'s alignment** - -Replace all of `src/view/markdown.rs` with: - -```rust -//! Markdown-to-styled-spans rendering for the chat transcript. -//! -//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and -//! translates each markdown construct into styled `ratatui::text::Span`s, -//! then re-wraps the flat span list to a target column width. -//! -//! Design: code blocks get a dark background with a labeled top bar, -//! headings are bold with distinct colors, blockquotes get a vertical -//! accent bar prefix, and inline code is highlighted with a background. -//! Deliberately adds no leading indentation of its own for paragraphs, -//! headings, or list bullets — the caller (`chat.rs`) owns column -//! alignment via its `PREFIX_WIDTH` scheme, so any indent added here -//! would only apply to a construct's first rendered line and throw -//! wrapped continuation lines out of alignment with it. Code-block lines -//! are the exception: every line gets its `" "` prefix independently -//! and consistently, so there's no first-line-only misalignment there. - -use ratatui::style::{Modifier, Style}; -use ratatui::text::Span; -use super::theme::Theme; - -/// Render a markdown string into styled terminal spans, word-wrapped to `width`. -/// -/// Flow: `pulldown_cmark` parses `text` into an event stream → each -/// Start/End/Text/Code/Break event is translated into styled `Span`s → -/// if `width > 0`, a second pass wraps long lines. -/// -/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` -/// turns it back into `Line`s for the Paragraph widget. -#[allow(clippy::too_many_lines)] -pub fn render_markdown(text: &str, width: u16) -> Vec> { - let mut spans = Vec::new(); - let parser = pulldown_cmark::Parser::new(text); - let mut in_code_block = false; - let mut in_heading = false; - let mut heading_level = 0; - - for event in parser { - match event { - pulldown_cmark::Event::Start(tag) => { - match tag { - pulldown_cmark::Tag::CodeBlock(_) => { - in_code_block = true; - // Code block top bar - spans.push(Span::styled( - "\n", - Style::default(), - )); - spans.push(Span::styled( - " ┌─ code ", - Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), - )); - spans.push(Span::styled( - "\n", - Style::default(), - )); - } - pulldown_cmark::Tag::Heading { level, .. } => { - in_heading = true; - heading_level = match level { - pulldown_cmark::HeadingLevel::H1 => 1, - pulldown_cmark::HeadingLevel::H2 => 2, - pulldown_cmark::HeadingLevel::H3 => 3, - _ => 4, - }; - // No prefix, we'll handle in the text events - } - pulldown_cmark::Tag::Item => { - // List item bullet - spans.push(Span::styled( - "• ", - Style::default().fg(Theme::PRIMARY), - )); - } - pulldown_cmark::Tag::Link { dest_url, .. } => { - spans.push(Span::styled( - "[", - Style::default().fg(Theme::INFO), - )); - // We push the URL as a tooltip-like suffix - // After the link text ends, we'll add the URL - spans.push(Span::styled( - format!("]({dest_url})"), - Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), - )); - } - pulldown_cmark::Tag::BlockQuote(_) => { - spans.push(Span::styled( - "▎", - Style::default().fg(Theme::BLOCKQUOTE_BAR), - )); - } - _ => {} - } - } - pulldown_cmark::Event::End(tag) => { - match tag { - pulldown_cmark::TagEnd::CodeBlock => { - in_code_block = false; - // Code block bottom bar - spans.push(Span::styled( - "\n └─\n", - Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), - )); - } - pulldown_cmark::TagEnd::Heading(_) => { - in_heading = false; - heading_level = 0; - spans.push(Span::raw("\n")); - } - pulldown_cmark::TagEnd::Paragraph => { - spans.push(Span::raw("\n\n")); - } - pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { - spans.push(Span::raw("\n")); - } - _ => {} - } - } - pulldown_cmark::Event::Text(text) => { - let s = text.to_string(); - if in_code_block { - spans.push(Span::styled( - format!(" {s}"), - Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), - )); - } else if in_heading { - let color = match heading_level { - 1 => Theme::PRIMARY, - 2 => Theme::INFO, - 3 => Theme::ACCENT_PURPLE, - _ => Theme::TEXT, - }; - spans.push(Span::styled( - s, - Style::default().fg(color).add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::raw(s)); - } - } - pulldown_cmark::Event::Code(text) => { - // Inline code with background - spans.push(Span::styled( - format!(" {text} "), - Style::default() - .fg(Theme::ACCENT_TEAL) - .bg(Theme::CODE_BAR) - .add_modifier(Modifier::BOLD), - )); - } - pulldown_cmark::Event::SoftBreak => { - spans.push(Span::raw(" ")); - } - pulldown_cmark::Event::HardBreak => { - spans.push(Span::raw("\n")); - } - _ => {} - } - } - - if width > 0 { - let mut spans_out = Vec::new(); - let mut line_len = 0; - let effective_width = (width as usize).saturating_sub(2); // leave margin - - for span in &spans { - let style = span.style; - let s = span.content.clone(); - let text_str = s.as_ref(); - let remaining = text_str.len(); - - if line_len + remaining > effective_width && line_len > 0 { - spans_out.push(Span::raw("\n")); - line_len = 0; - } - - spans_out.push(Span::styled(text_str.to_string(), style)); - - if text_str.contains('\n') { - line_len = text_str.split('\n').next_back().unwrap_or("").len(); - } else { - line_len += remaining; - } - } - spans = spans_out; - } - - spans -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cargo test view::chat::tests` -Expected: PASS (4 tests) - -- [ ] **Step 6: Full build + lint gate** - -Run: `cargo build && cargo clippy -- -D warnings` -Expected: both succeed with zero warnings. - -- [ ] **Step 7: Commit** - -```bash -git add src/view/chat.rs src/view/markdown.rs -git commit -m "$(cat <<'EOF' -feat(tui): rombak rendering chat jadi format log rapat - -markdown.rs juga disesuaikan: indentasi paragraf/heading bawaannya -dilepas supaya tidak bentrok dengan indent PREFIX_WIDTH di chat.rs -(baris pertama vs baris wrap lanjutan jadi sejajar). - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -### Task 3: `/todo` and `/usage` open commands - -**Files:** -- Modify: `src/controller/command.rs` (add 2 `Command` variants, 2 parse arms, new `#[cfg(test)] mod tests`) -- Modify: `src/app/runtime/commands.rs` (add 2 `apply_command` arms) -- Modify: `src/app/state/misc.rs:78-94` (`COMMANDS` const — add 2 entries) -- Modify: `src/resources.rs:31-40` (`HELP_TEXT` — add 2 lines) - -**Interfaces:** -- Consumes: `crate::app::runtime::actions::Action::OpenOverlay(Overlay)` (existing, generic — confirmed in `app/runtime/actions/mod.rs:158-164` that `OpenOverlay` needs no per-overlay-variant handling for non-list overlays); `crate::app::state::types::Overlay::{Todo, Usage}` (existing variants). -- Produces: `Command::TodoOpen`, `Command::UsageOpen` in `controller::command` — not consumed by any other task in this plan (the sidebar hint text in Task 4 is plain hard-coded text, not a type-level dependency), but must land before Task 4 so that hint text is truthful the moment it's written. - -- [ ] **Step 1: Write the failing tests** - -`src/controller/command.rs` currently has no test module. Add this to the bottom of the file: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_todo_open() { - assert_eq!(parse_command("/todo"), Command::TodoOpen); - } - - #[test] - fn parses_usage_open() { - assert_eq!(parse_command("/usage"), Command::UsageOpen); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test controller::command::tests` -Expected: FAIL to compile — `Command::TodoOpen` / `Command::UsageOpen` don't exist yet. - -- [ ] **Step 3: Add the two `Command` variants** - -In `src/controller/command.rs`, in the `pub enum Command` block, add two variants after `WorkflowRun`: - -```rust - WorkflowRun { - script: String, - }, - TodoOpen, - UsageOpen, - Unknown(String), -``` - -- [ ] **Step 4: Add the two parse arms** - -In the same file, in `parse_command`'s `match cmd` block, add these arms right after the `"/workflow"` arms (before the final `_ => Command::Unknown(cmd.to_string())`): - -```rust - "/todo" => Command::TodoOpen, - "/usage" => Command::UsageOpen, -``` - -- [ ] **Step 5: Wire the new commands to actions** - -`apply_command`'s `match command` in `commands.rs` has **no wildcard arm** — it's exhaustive over every `Command` variant. After Step 3 added `TodoOpen`/`UsageOpen` to the enum, the whole crate stops compiling until this match handles them too, so this step must land before the next one (running the tests requires the full crate to build, not just `command.rs`). - -In `src/app/runtime/commands.rs`, in `apply_command`'s `match command` block, add these arms right after the `Command::WorkflowRun` arm: - -```rust - Command::TodoOpen => { - vec![Action::OpenOverlay(Overlay::Todo)] - } - Command::UsageOpen => { - vec![Action::OpenOverlay(Overlay::Usage)] - } -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cargo test controller::command::tests` -Expected: PASS (2 tests) - -- [ ] **Step 7: Make the commands discoverable via autocomplete** - -In `src/app/state/misc.rs`, in the `COMMANDS` const, add two entries right after `"/workflow run"`: - -```rust -const COMMANDS: &[&str] = &[ - "/help", - "/quit", - "/clear", - "/lesson", - "/login", - "/login zen", - "/login openai", - "/edit", - "/mcp add", - "/model", - "/model ls", - "/model add", - "/workflow", - "/workflow run", - "/todo", - "/usage", - "/compact", -]; -``` - -- [ ] **Step 8: Document the commands in the in-app help screen** - -In `src/resources.rs`, in `HELP_TEXT`'s `Input:` section, add two lines right after `/mode workflow`: - -``` - /workflow Open workflow panel - /workflow run

Run a workflow with prompt

- /mode workflow Open workflow panel - /todo Open task list - /usage Open usage details - /compact Compact conversation history -``` - -- [ ] **Step 9: Full build + lint gate** - -Run: `cargo build && cargo clippy -- -D warnings && cargo test` -Expected: all succeed with zero warnings, all tests pass. - -- [ ] **Step 10: Commit** - -```bash -git add src/controller/command.rs src/app/runtime/commands.rs src/app/state/misc.rs src/resources.rs -git commit -m "$(cat <<'EOF' -feat(tui): tambah command /todo dan /usage untuk buka overlay - -Overlay Todo dan Usage sebelumnya tidak punya trigger sama sekali di -jalur interaksi normal (cuma bisa lewat restore snapshot sesi) -- -sekarang mengikuti pola /workflow yang sudah ada. - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -### Task 4: Build and wire the persistent sidebar - -**This task is intentionally large — see the Global Constraints note on why it isn't split into three.** In short: `dead_code = "deny"` fails plain `cargo build` for any unreachable `pub fn` in this binary-only crate, and the new sidebar's render functions are only reachable once they're wired into `draw()`. Splitting this into "add the widgets" / "add the module" / "wire it in" as separate tasks would leave a broken `cargo build` at each intermediate boundary. The step order below is deliberate: **all pure, independently-testable logic comes first (Steps 1–8, each with its own red/green cycle); all rendering glue and wiring comes after (Steps 9–12) with no test/build checkpoint in between, ending in one comprehensive gate (Step 13).** - -**Files:** -- Modify: `src/view/mod.rs` (two new shared helpers, module registration, `draw()` rewiring, deletion of `render_todo_panel`) -- Modify: `src/view/workflow.rs` (add `draw_workflow_widget`, extend the `use` import) -- Create: `src/view/sidebar.rs` - -**Interfaces:** -- Consumes: `Theme::*` from Task 1; `crate::app::workflow::engine::{AgentState, WorkflowAgent, AgentStatus}` (existing: `WorkflowAgent { id: String, name: String, status: AgentStatus }`); existing private `state_icon(AgentState) -> &'static str`, `state_label(AgentState) -> &'static str`, `state_color(AgentState) -> Color` in `workflow.rs` (unchanged, reused); `crate::app::state::runtime::UsageStats` (existing: `tokens_in: u64, tokens_out: u64, review_tokens: u64, api_calls: u64`, derives `Default, Copy`); `state.misc.todo_content: String`, `state.session_runtime: Option` (existing). -- Produces: - - `pub(crate) fn split_for_display(items: &[T], max_visible: usize) -> (&[T], usize)` in `view/mod.rs`. - - `pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static>` in `view/mod.rs`. - - `pub fn draw_workflow_widget(frame: &mut Frame, area: Rect, state: &AppStateRest)` in `view/workflow.rs`. - - `pub(crate) struct UsageSummary { main_tokens, self_learning_tokens, total_tokens, api_calls: u64, elapsed_hours, elapsed_minutes, elapsed_seconds: i64 }` and `pub(crate) fn compute_usage_summary(usage: &UsageStats, session_start: i64, now_ms: i64) -> UsageSummary` in `view/sidebar.rs` — consumed by Task 5 (`mod.rs`'s `Overlay::Usage` arm) as a second call site. - - `pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &AppStateRest)` in `view/sidebar.rs`. - -#### Part A — pure logic, tested (Steps 1–8) - -- [ ] **Step 1: Write the failing tests for the shared `mod.rs` helpers** - -Add to the bottom of `src/view/mod.rs` (there is no test module in this file yet): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn split_for_display_returns_everything_when_it_fits() { - let items = vec![1, 2, 3]; - let (visible, hidden) = split_for_display(&items, 5); - assert_eq!(visible, &[1, 2, 3]); - assert_eq!(hidden, 0); - } - - #[test] - fn split_for_display_truncates_and_counts_hidden() { - let items = vec![1, 2, 3, 4, 5]; - let (visible, hidden) = split_for_display(&items, 2); - assert_eq!(visible, &[1, 2]); - assert_eq!(hidden, 3); - } - - #[test] - fn overflow_hint_line_mentions_hidden_count_and_command() { - let line = overflow_hint_line(3, "/todo"); - let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); - assert!(text.contains("+3 more")); - assert!(text.contains("/todo")); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test view::tests` -Expected: FAIL to compile — `split_for_display` and `overflow_hint_line` don't exist yet. - -- [ ] **Step 3: Implement the two helpers** - -Add this to `src/view/mod.rs` right after the `centered_rect` function (before the test module from Step 1): - -```rust -/// Split `items` into the slice that fits within `max_visible` entries and -/// the count of items hidden beyond that limit. -/// -/// Used by sidebar widgets (Workflow, Tasks) to cap their content to the -/// available panel height instead of overflowing it. -/// -/// Return: `(visible_slice, hidden_count)` — `hidden_count` is `0` when -/// everything fits. -pub(crate) fn split_for_display(items: &[T], max_visible: usize) -> (&[T], usize) { - if items.len() <= max_visible { - (items, 0) - } else { - (&items[..max_visible], items.len() - max_visible) - } -} - -/// Build the dim trailing hint line a sidebar widget shows when its -/// content is truncated, pointing at the slash command that opens the -/// full "expand" overlay for that widget (e.g. `"/workflow"`, `"/todo"`). -pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> { - Line::from(Span::styled( - format!(" +{hidden} more — {command}"), - Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), - )) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test view::tests` -Expected: PASS (3 tests) - -- [ ] **Step 5: Register the new `sidebar` module and write the failing test for `compute_usage_summary`** - -In `src/view/mod.rs`, change: - -```rust -pub mod chat; -pub mod markdown; -pub mod status; -pub mod theme; -pub mod workflow; -``` - -to: - -```rust -pub mod chat; -pub mod markdown; -pub mod sidebar; -pub mod status; -pub mod theme; -pub mod workflow; -``` - -Create `src/view/sidebar.rs` with just this much: - -```rust -//! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage -//! widgets stacked in three vertical thirds — the "glance" view that -//! complements the `Overlay::Todo` / `Overlay::Usage` "expand" views in -//! `view/mod.rs`. - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::state::runtime::UsageStats; - - #[test] - fn compute_usage_summary_splits_main_and_self_learning_tokens() { - let usage = UsageStats { - tokens_in: 100, - tokens_out: 50, - review_tokens: 30, - api_calls: 4, - ..UsageStats::default() - }; - let summary = compute_usage_summary(&usage, 0, 0); - assert_eq!(summary.total_tokens, 150); - assert_eq!(summary.self_learning_tokens, 30); - assert_eq!(summary.main_tokens, 120); - assert_eq!(summary.api_calls, 4); - } - - #[test] - fn compute_usage_summary_splits_elapsed_time() { - let usage = UsageStats::default(); - // 1h 2m 3s = 3_600_000 + 120_000 + 3_000 ms - let summary = compute_usage_summary(&usage, 0, 3_723_000); - assert_eq!(summary.elapsed_hours, 1); - assert_eq!(summary.elapsed_minutes, 2); - assert_eq!(summary.elapsed_seconds, 3); - } -} -``` - -- [ ] **Step 6: Run tests to verify they fail** - -Run: `cargo test view::sidebar::tests` -Expected: FAIL to compile — `compute_usage_summary` doesn't exist yet. (`view::tests` from Steps 1–4 stays green; nothing there was touched.) - -- [ ] **Step 7: Implement `compute_usage_summary` and `UsageSummary`** - -Add this to `src/view/sidebar.rs`, above the test module from Step 5: - -```rust -/// Derived, display-ready usage numbers shared by the compact Usage -/// widget and the `Overlay::Usage` expand view. -pub(crate) struct UsageSummary { - pub main_tokens: u64, - pub self_learning_tokens: u64, - pub total_tokens: u64, - pub api_calls: u64, - pub elapsed_hours: i64, - pub elapsed_minutes: i64, - pub elapsed_seconds: i64, -} - -/// Compute display-ready usage numbers from raw session counters. -/// -/// Flow: total = tokens_in + tokens_out → main = total - review_tokens -/// (the self-learning share) → elapsed = now_ms - session_start, split -/// into h/m/s. -/// -/// Why `now_ms` is a parameter instead of reading the clock internally: -/// keeps this function pure and deterministic for testing. -pub(crate) fn compute_usage_summary( - usage: &crate::app::state::runtime::UsageStats, - session_start: i64, - now_ms: i64, -) -> UsageSummary { - let total_tokens = usage.tokens_in.saturating_add(usage.tokens_out); - let self_learning_tokens = usage.review_tokens; - let main_tokens = total_tokens.saturating_sub(self_learning_tokens); - let elapsed_ms = now_ms.saturating_sub(session_start); - let elapsed_hours = elapsed_ms / 3_600_000; - let elapsed_minutes = (elapsed_ms % 3_600_000) / 60_000; - let elapsed_seconds = (elapsed_ms % 60_000) / 1000; - UsageSummary { - main_tokens, - self_learning_tokens, - total_tokens, - api_calls: usage.api_calls, - elapsed_hours, - elapsed_minutes, - elapsed_seconds, - } -} -``` - -- [ ] **Step 8: Run tests to verify they pass** - -Run: `cargo test view::sidebar::tests` -Expected: PASS (2 tests) - -#### Part B — rendering glue and wiring, no checkpoint until the end (Steps 9–12) - -All the pure logic above is now written and green. The steps below add `pub fn`s that call it but aren't themselves reachable from `main()` until Step 11 — **do not run `cargo build`, `cargo clippy`, or `cargo test` between Steps 9 and 12.** The first check after this point is Step 13. - -- [ ] **Step 9: Add the compact Workflow widget** - -In `src/view/workflow.rs`, change the top `use` line: - -```rust -use crate::app::workflow::engine::AgentState; -``` - -to: - -```rust -use crate::app::workflow::engine::{AgentState, WorkflowAgent}; -``` - -Then add this function at the end of the file (after `build_session_lines`, before the trailing `use ratatui::style::Color;` line — leave that trailing import where it is): - -```rust -/// Render the compact Workflow widget for the persistent sidebar: one -/// line per agent (icon + name), truncated to whatever fits with a -/// trailing "+N more" hint pointing at `/workflow` for the full view. -/// -/// Flow: bordered `Block` titled "Workflow" → empty state if no agents → -/// else `split_for_display` caps the list to the inner height (minus one -/// row for the hint line, if needed) → one line per visible agent. -pub fn draw_workflow_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { - let block = Block::default() - .title(Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)); - let budget = (block.inner(area).height as usize).max(1); - - let agents = &state.workflow_engine.agents; - let lines: Vec = if agents.is_empty() { - vec![Line::from(Span::styled( - " No workflow running.", - Style::default().fg(Theme::TEXT_DIM), - ))] - } else { - let show_hint = agents.len() > budget; - let item_budget = if show_hint { budget.saturating_sub(1).max(1) } else { budget }; - let (visible, hidden) = super::split_for_display(agents.as_slice(), item_budget); - let mut lines: Vec = visible.iter().map(workflow_agent_line).collect(); - if show_hint { - lines.push(super::overflow_hint_line(hidden, "/workflow")); - } - lines - }; - - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, area); -} - -/// One compact line for a single agent: state icon + name, state-colored. -fn workflow_agent_line(agent: &WorkflowAgent) -> Line<'static> { - let color = state_color(agent.status.state); - let icon = state_icon(agent.status.state); - Line::from(vec![ - Span::styled(format!(" {icon} "), Style::default().fg(color).add_modifier(Modifier::BOLD)), - Span::styled(agent.name.clone(), Style::default().fg(Theme::TEXT)), - ]) -} -``` - -- [ ] **Step 10: Add the Tasks widget, Usage widget, and sidebar assembly** - -Add this to `src/view/sidebar.rs`, above the test module (and above the `UsageSummary`/`compute_usage_summary` code from Step 7 — order within the file doesn't matter, but keep it above the `#[cfg(test)]` block): - -```rust -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Style, Modifier}; -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. -pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ]) - .split(area); - - super::workflow::draw_workflow_widget(frame, chunks[0], state); - draw_tasks_widget(frame, chunks[1], state); - draw_usage_widget(frame, chunks[2], state); -} - -/// Compact Tasks widget: `misc.todo_content` split into lines, truncated -/// 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))) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)); - let budget = (block.inner(area).height as usize).max(1); - - let content = &state.misc.todo_content; - 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)))] - } else { - let show_hint = task_lines.len() > 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)))) - .collect(); - if show_hint { - lines.push(super::overflow_hint_line(hidden, "/todo")); - } - lines - }; - - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, area); -} - -/// Compact Usage widget: total tokens + session clock. Always fits (the -/// summary is a fixed handful of lines), so there is no overflow hint — -/// the `Overlay::Usage` "expand" view adds edit/review/lesson counters on -/// top of this same summary rather than showing more of a truncated list. -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))) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)); - - let lines: Vec = if let Some(ref rt) = state.session_runtime { - let now_ms = chrono::Utc::now().timestamp_millis(); - let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms); - vec![ - Line::from(Span::styled( - format!(" {} tok", summary.total_tokens), - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - format!(" {}h {}m {}s", 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)))] - }; - - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, area); -} -``` - -- [ ] **Step 11: Wire the sidebar into `draw()`** - -In `src/view/mod.rs`, replace the whole `pub fn draw(...)` function (from `pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {` through its closing `}`) with: - -```rust -/// Top-level render entry point called once per TUI frame. -pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { - let area = frame.area(); - - // ── Determine if the terminal is wide enough for the persistent - // dashboard sidebar (Workflow / Tasks / Usage). Below this, chat - // reclaims the full width — same width-driven-collapse pattern the - // old single-widget todo panel used, just with a wider threshold - // since this sidebar holds three stacked widgets, not one. - const SIDEBAR_MIN_WIDTH: u16 = 90; - let show_sidebar = area.width > SIDEBAR_MIN_WIDTH; - let (main_area, sidebar_area) = if show_sidebar { - let h_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(40), - Constraint::Length(30), - ]) - .split(area); - (h_chunks[0], Some(h_chunks[1])) - } else { - (area, None) - }; - - // ── Vertical layout: chat / input / status ─────────────────────────── - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), - Constraint::Length(3), - Constraint::Length(1), - ]) - .split(main_area); - - let chat_area = chunks[0]; - let input_area = chunks[1]; - let status_area = chunks[2]; - - // ── Render main area (overlay or chat) ─────────────────────────────── - if state.misc.overlay.is_active() { - let overlay = state.misc.overlay; - render_overlay(frame, chat_area, overlay, state); - } else { - render_main_panel(frame, chat_area, state); - } - - // ── Input bar ──────────────────────────────────────────────────────── - render_input_bar(frame, input_area, state); - - // ── Status bar ─────────────────────────────────────────────────────── - status::draw_status_bar(frame, status_area, state); - - // ── Dashboard sidebar ──────────────────────────────────────────────── - if let Some(sidebar_rect) = sidebar_area { - sidebar::draw_sidebar(frame, sidebar_rect, state); - } - - // ── Toasts (top-right floating) ────────────────────────────────────── - render_toasts(frame, state); -} -``` - -Note what changed from the old version: `Overlay::Todo` is no longer special-cased out of `render_overlay` (it used to be skipped so the side panel could take over) — it now behaves like every other overlay, because the sidebar's Tasks widget (always visible above the width threshold, regardless of overlay state) already covers the "glance" case, and `Overlay::Todo` is fixed in Task 5 to be the real "expand" view. - -- [ ] **Step 12: Delete the now-superseded `render_todo_panel` function** - -In the same file, delete this whole function (it's fully replaced by `sidebar::draw_tasks_widget` for the compact view and the Task-5-fixed `Overlay::Todo` arm for the expand view): - -```rust -fn render_todo_panel( - frame: &mut Frame, - area: Rect, - state: &crate::app::state::rest::AppStateRest, -) { - let block = Block::default() - .title(" 📋 Tasks ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::ACCENT_PURPLE)) - .style(Style::default().bg(Theme::BG)); - - let content = if state.misc.todo_content.is_empty() { - " No tasks yet." - } else { - &state.misc.todo_content - }; - - let paragraph = Paragraph::new(content) - .block(block) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); -} -``` - -#### Part C — the checkpoint - -- [ ] **Step 13: Full build + lint gate** - -Run: `cargo build && cargo clippy -- -D warnings && cargo test` -Expected: all succeed with zero warnings, all tests pass (including the 5 from Steps 1–8). Every function added in Part B is now reachable from `main()` via `draw() → sidebar::draw_sidebar() → {draw_workflow_widget, draw_tasks_widget, draw_usage_widget} → {split_for_display, overflow_hint_line, compute_usage_summary}`, so there should be no `dead_code` errors. - -- [ ] **Step 14: Manual smoke check** - -Run: `cargo run` -Expected: app launches; chat renders in a bordered panel; if your terminal is wider than 90 columns, a right-hand sidebar shows "Workflow" / "Tasks" / "Usage" panels with placeholder text ("No workflow running.", "No tasks yet.", token/session line). Resize the terminal narrower than 90 columns and confirm the sidebar disappears and chat reclaims the full width. Quit with the existing quit flow (Ctrl+C then confirm). - -- [ ] **Step 15: Commit** - -```bash -git add src/view/mod.rs src/view/workflow.rs src/view/sidebar.rs -git commit -m "$(cat <<'EOF' -feat(tui): tambah dan pasang sidebar dashboard permanen - -Sidebar kanan permanen (Workflow/Tasks/Usage) menggantikan panel todo -ad-hoc yang lama. Widget baca state yang sudah ada, tidak ada perubahan -skema AppStateRest. - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -### Task 5: Fix Todo/Usage overlay bodies into real "expand" views - -**Files:** -- Modify: `src/view/mod.rs` (the `Overlay::Todo` and `Overlay::Usage` arms inside `render_overlay`) - -**Interfaces:** -- Consumes: `sidebar::compute_usage_summary` (Task 4). -- Produces: no new interface. - -No new pure logic here (this reuses Task 4's already-tested `compute_usage_summary`) — verify with build + clippy + the manual pass in Task 7. This task is safe as a standalone build checkpoint: it only edits existing, already-reachable match-arm bodies inside `render_overlay` (no new `pub` items), and Task 4's Step 13 already confirmed the crate builds clean before this task starts. - -- [ ] **Step 1: Fix the `Overlay::Todo` arm** - -In `src/view/mod.rs`'s `render_overlay` function, find the `crate::app::state::types::Overlay::Todo => { ... }` arm. It currently shows an unrelated message-count dump: - -```rust - // ── Todo ────────────────────────────────────────────────────── - crate::app::state::types::Overlay::Todo => { - let block = block - .title(Span::styled(" 📋 Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) - .border_style(Style::default().fg(Theme::ACCENT_PURPLE)); - let msg_count = state.transcript_cache.messages.len(); - let lines = vec![ - Line::from(Span::styled( - " Session Activity", - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(Span::raw("")), - Line::from(Span::styled( - format!(" Messages: {msg_count}"), - Style::default().fg(Theme::INFO), - )), - Line::from(Span::styled( - format!(" Overlay: {:?}", state.misc.overlay), - Style::default().fg(Theme::TEXT_DIM), - )), - ]; - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, overlay_area); - } -``` - -Replace the whole arm with (this is what the now-deleted `render_todo_panel` used to do, adapted to render into `overlay_area`, with the emoji already dropped per the Task-6 rule — no need to touch this title again in Task 6): - -```rust - // ── Todo ────────────────────────────────────────────────────── - crate::app::state::types::Overlay::Todo => { - let block = block - .title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) - .border_style(Style::default().fg(Theme::ACCENT_PURPLE)); - let content = if state.misc.todo_content.is_empty() { - " No tasks yet." - } else { - &state.misc.todo_content - }; - let paragraph = Paragraph::new(content) - .block(block) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, overlay_area); - } -``` - -- [ ] **Step 2: Route the `Overlay::Usage` arm through `compute_usage_summary`** - -Find the `crate::app::state::types::Overlay::Usage => { ... }` arm (it currently computes token/elapsed math inline). Replace the whole arm with: - -```rust - // ── Usage ──────────────────────────────────────────────────── - crate::app::state::types::Overlay::Usage => { - let block = block - .title(Span::styled(" Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) - .border_style(Style::default().fg(Theme::INFO)); - let runtime = state.session_runtime.as_ref(); - let now_ms = chrono::Utc::now().timestamp_millis(); - let summary = runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms)); - let (edit_count, lesson_count, review_count, consec_empty) = runtime - .map_or((0, 0, 0, 0), |r| { - (r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews) - }); - let mut lines = vec![ - Line::from(Span::styled( - " Token Usage", - Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), - )), - Line::from(Span::raw("")), - ]; - if let Some(s) = &summary { - lines.push(Line::from(Span::styled( - format!(" Main agent: {} tokens", s.main_tokens), - Style::default().fg(Theme::TEXT), - ))); - lines.push(Line::from(Span::styled( - format!(" Self-learning: {} tokens", s.self_learning_tokens), - Style::default().fg(Theme::TEXT_MUTED), - ))); - lines.push(Line::from(Span::styled( - format!(" Total: {} tokens", s.total_tokens), - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - ))); - lines.push(Line::from(Span::styled( - format!(" API calls: {}", s.api_calls), - Style::default().fg(Theme::TEXT), - ))); - } else { - lines.push(Line::from(Span::styled( - " No active session.", - Style::default().fg(Theme::TEXT_DIM), - ))); - } - lines.push(Line::from(Span::raw(""))); - lines.push(Line::from(Span::styled( - " Activity", - Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), - ))); - lines.push(Line::from(Span::styled( - format!(" Edits: {edit_count}"), - Style::default().fg(Theme::TEXT), - ))); - lines.push(Line::from(Span::styled( - format!(" Reviews: {review_count}"), - Style::default().fg(Theme::TEXT), - ))); - lines.push(Line::from(Span::styled( - format!(" Lessons: {lesson_count}"), - Style::default().fg(Theme::TEXT_MUTED), - ))); - lines.push(Line::from(Span::styled( - format!(" Empty reviews: {}", - if consec_empty > 3 { format!("{consec_empty} ⚠") } else { consec_empty.to_string() }, - ), - Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }), - ))); - if let Some(s) = &summary { - lines.push(Line::from(Span::raw(""))); - lines.push(Line::from(Span::styled( - format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds), - Style::default().fg(Theme::TEXT_DIM), - ))); - } - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, overlay_area); - } -``` - -- [ ] **Step 3: Build + lint gate** - -Run: `cargo build && cargo clippy -- -D warnings` -Expected: both succeed with zero warnings. - -- [ ] **Step 4: Manual smoke check** - -Run: `cargo run`, send at least one message so `session_runtime` has real token counts, then trigger `/todo` and `/usage` (see Task 3). Confirm: `/todo` shows the actual task list content (not a message count), `/usage` shows the same numbers the sidebar's Usage widget shows plus the extra Edits/Reviews/Lessons/Session-clock detail. - -- [ ] **Step 5: Commit** - -```bash -git add src/view/mod.rs -git commit -m "$(cat <<'EOF' -fix(tui): perbaiki isi overlay Todo dan Usage jadi tampilan detail nyata - -Overlay Todo sebelumnya menampilkan jumlah pesan yang tidak relevan, -bukan isi task list. Overlay Usage sekarang pakai compute_usage_summary -yang sama dengan widget sidebar (DRY, angka konsisten). - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -### Task 6: Drop decorative emoji from overlay titles - -**Files:** -- Modify: `src/view/mod.rs` (14 of the 16 overlay-title strings inside `render_overlay` — `Todo` and `Usage` were already done emoji-free in Task 5) -- Modify: `src/view/workflow.rs` (the `draw_workflow_panel` title — the 15th; `draw_workflow_widget` from Task 4 was already emoji-free) - -Purely cosmetic string edits, no logic change — no unit test applies. Verify with build + clippy + the manual pass in Task 7. Safe as a standalone build checkpoint: no new `pub` items, only string-literal edits inside already-reachable code. - -Scope note: this only touches the 16 `Overlay`-variant titles. It does **not** touch `status.rs`'s `" ⚡zesdex "` app branding (not an overlay title — out of the approved spec's icon rule) or the autocomplete dropdown's "⌘ Commands" title (also not an `Overlay` variant). - -- [ ] **Step 1: Edit each title in `src/view/mod.rs`'s `render_overlay`** - -Apply these 13 exact string replacements (each is a distinct `.title(Span::styled(...))` call inside its own match arm — search for the emoji to locate each one unambiguously). `Learning`'s right pane (`" Details "`) has no emoji already and needs no edit — it's the 14th of the "14 of 16" non-Todo/Usage arms referenced in this task's Files note. - -| Overlay arm | Before | After | -|---|---|---| -| Help | `" ❓ Help "` | `" Help "` | -| Settings | `" ⚙ Settings "` | `" Settings "` | -| Bash | `" 💻 Bash Jobs "` | `" Bash Jobs "` | -| QuitConfirm | `" 🚪 Quit "` | `" Quit "` | -| KeyInput | `" 🔑 API Key "` | `" API Key "` | -| Editor | `" ✏️ Editor "` | `" Editor "` | -| Effort | `" 🎯 Effort Level "` | `" Effort Level "` | -| Mcp | `" 🔌 MCP Servers "` | `" MCP Servers "` | -| Rewind | `" ⏪ Rewind "` | `" Rewind "` | -| Learning (left pane) | `" 📚 Lessons "` | `" Lessons "` | -| Loading | `" ⏳ Loading "` | `" Loading "` | -| ModelSelector | `" 🧠 Model Selector "` | `" Model Selector "` | -| ClearConfirm | `" 🗑️ Clear Transcript "` | `" Clear Transcript "` | - -- [ ] **Step 2: Edit the Workflow overlay's title in `src/view/workflow.rs`** - -In `draw_workflow_panel`, change: - -```rust - let title = Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); -``` - -to: - -```rust - let title = Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); -``` - -- [ ] **Step 3: Build + lint gate** - -Run: `cargo build && cargo clippy -- -D warnings` -Expected: both succeed with zero warnings. - -- [ ] **Step 4: Manual smoke check** - -Run: `cargo run` and open each overlay (Ctrl+C for QuitConfirm, `/help`, `/mcp`, `/model`, `/workflow`, `/todo`, `/usage`, `/clear` for ClearConfirm, `/lesson`, `/edit `) — confirm no title has a leading emoji glyph, and the status bar's `⚡zesdex` branding is unchanged. - -- [ ] **Step 5: Commit** - -```bash -git add src/view/mod.rs src/view/workflow.rs -git commit -m "$(cat <<'EOF' -style(tui): hapus emoji dekoratif dari judul overlay - -Co-Authored-By: Claude Sonnet 5 -EOF -)" -``` - ---- - -### Task 7: Manual verification pass - -No code changes. This is the closing checklist called for by the spec's Testing section (no automated visual/snapshot coverage exists for `view/`/`controller/`, so the golden paths are verified by hand). - -- [ ] **Step 1: Full workspace gate** - -Run: `cargo build --release && cargo test && cargo clippy -- -D warnings` -Expected: all three succeed — this matches `.github/workflows/ci.yml` exactly. - -- [ ] **Step 2: Launch and exercise the golden paths** - -Run: `cargo run`, then work through each of these and confirm the described behavior: - -1. **Send a chat message.** Type text (no leading `/`) and press Enter. Confirm the transcript shows `you HH:MM ` with no card border/badge, and the assistant's reply appears as `ai HH:MM ` with a single blank line between your turn and its turn (not zero, not more than one). -2. **Tool activity renders as a sub-line.** If the assistant's turn includes a tool call, confirm it renders as an indented `↳ ...` dim line directly under the assistant's turn, with no blank line separating it and no `tool HH:MM` header of its own. -3. **Trigger a workflow.** Run `/workflow run `. Confirm the sidebar's Workflow widget (if terminal width > 90 cols) updates live with agent state icons as it runs, and `/workflow` (no args) still opens the full detail overlay. -4. **Open every overlay** and confirm each renders with the new palette and no title emoji: `/help`, `/mcp`, `/model`, `/todo`, `/usage`, `/lesson`, `/clear` (ClearConfirm), `/edit `, Ctrl+C (QuitConfirm). For `/todo`, confirm it shows real task content (or "No tasks yet."), not a message count. -5. **Resize across the sidebar threshold.** Shrink the terminal to under 90 columns — confirm the sidebar disappears and chat takes the full width with no layout glitch; widen back past 90 — confirm the sidebar reappears with Workflow/Tasks/Usage in that order. -6. **Scroll a long transcript.** Send enough messages to overflow the visible area, scroll up (per existing scroll keys) and confirm the `── N% ↑` indicator appears in the chat panel's title and wrapped lines stay aligned under the content column (not sliding under the role label). - -- [ ] **Step 3: Report** - -No commit for this task (no code changes). If any check in Step 2 fails, stop and fix it as a follow-up task before considering this plan complete — do not silently note it and move on. diff --git a/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md b/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md deleted file mode 100644 index 96303b5..0000000 --- a/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md +++ /dev/null @@ -1,2342 +0,0 @@ -# Diff View, @File-Mention & Clipboard Copy Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give `edit`/`write` real unified diffs (with colored rendering in chat), add fuzzy `@file` mention autocomplete to the chat input, and add an OSC52 clipboard-copy hotkey for the last assistant message — three independent, small-to-medium features bundled into one plan per user request. - -**Architecture:** Diff view wraps `similar`'s unified-diff output in a ` ```diff ` fenced block returned by `edit`/`write`, rendered through an extended `markdown.rs` that keeps diff-line colors even in the "dim" tool-output mode. File mention reuses `ignore::Walk` (already used by `search.rs`) to build a background-populated file index, fuzzy-matched via `nucleo-matcher`, spliced into the input buffer at the `@` trigger. Clipboard copy sets a `pending_clipboard_copy` state field in `handle_key` (which may run daemon-side) and the actual OSC52 terminal write happens wherever the real terminal lives (single-process loop, or the attach client after a new `DaemonFrame::ClipboardCopy`). - -**Tech Stack:** Rust, ratatui/crossterm TUI, `similar` (new), `nucleo-matcher` (new), `ignore` (existing), `base64` (existing). - -## Global Constraints - -- New dependencies: `similar = "3"`, `nucleo-matcher = "0.3"`. No other new dependencies (clipboard uses the existing `base64` crate). -- `MAX_DIFF_LINES = 200` — diffs longer than this are truncated with `"... ({N} more lines truncated)"`. -- Mention index cap: 50,000 entries across all workspace roots. -- Every `pub fn`/`pub struct`/`pub enum` needs a doc comment (CLAUDE.md convention); non-trivial private functions (≥10 lines) too. -- Tests are inline `#[cfg(test)] mod tests` blocks in the same file, per CLAUDE.md — no separate `tests/` directory. -- No compiler/clippy bypass attributes (`#[allow(dead_code)]` etc.) to silence warnings — fix the underlying issue instead. -- Specs: `docs/superpowers/specs/2026-07-15-diff-view-design.md`, `docs/superpowers/specs/2026-07-15-file-mention-design.md`, `docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md`. - ---- - -## Part A — Diff view for `edit`/`write` - -### Task 1: Add `similar` dependency - -**Files:** -- Modify: `Cargo.toml` - -**Interfaces:** -- Produces: `similar::TextDiff`, `similar::udiff::UnifiedDiff` available to `src/tool/fs/*.rs`. - -- [ ] **Step 1: Add the dependency** - -In `Cargo.toml`, in the `[dependencies]` block, add this line right after `pulldown-cmark = { version = "0.13", default-features = false }`: - -```toml -similar = "3" -``` - -- [ ] **Step 2: Verify it builds** - -Run: `cargo check` -Expected: compiles with no errors (a new `similar` entry appears in `Cargo.lock`). - -- [ ] **Step 3: Commit** - -```bash -git add Cargo.toml Cargo.lock -git commit -m "chore: Tambah dependency similar untuk diff computation" -``` - ---- - -### Task 2: Shared diff-truncation helper - -**Files:** -- Modify: `src/tool/fs/helpers.rs` - -**Interfaces:** -- Produces: `pub const MAX_DIFF_LINES: usize`, `pub fn truncate_diff(diff: &str) -> String`. - -- [ ] **Step 1: Write the failing tests** - -Add to the existing `#[cfg(test)] mod tests` block in `src/tool/fs/helpers.rs` (after the existing `test_arg_str_null` test, before the closing `}`): - -```rust - #[test] - fn test_truncate_diff_under_limit_unchanged() { - let diff = "line1\nline2\nline3"; - assert_eq!(truncate_diff(diff), diff); - } - - #[test] - fn test_truncate_diff_over_limit_truncates() { - 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); - } -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test tool::fs::helpers::tests -- truncate_diff` -Expected: FAIL with "cannot find function `truncate_diff`" / "cannot find value `MAX_DIFF_LINES`" - -- [ ] **Step 3: Implement `truncate_diff`** - -Add this above the `#[cfg(test)]` line in `src/tool/fs/helpers.rs` (after the existing `not_found_help` function): - -```rust -/// Maximum number of lines a diff block may contain before being truncated. -pub const MAX_DIFF_LINES: usize = 200; - -/// Cap a unified diff at `MAX_DIFF_LINES` lines, appending a truncation note. -/// -/// Return: `diff` unchanged if it's within the limit; otherwise the first -/// `MAX_DIFF_LINES` lines followed by `"... ({N} more lines truncated)"`. -pub fn truncate_diff(diff: &str) -> String { - let lines: Vec<&str> = diff.lines().collect(); - if lines.len() <= MAX_DIFF_LINES { - return diff.to_string(); - } - let remaining = lines.len() - MAX_DIFF_LINES; - format!("{}\n... ({remaining} more lines truncated)", lines[..MAX_DIFF_LINES].join("\n")) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test tool::fs::helpers::tests` -Expected: PASS (all tests in the module, old and new) - -- [ ] **Step 5: Commit** - -```bash -git add src/tool/fs/helpers.rs -git commit -m "feat: Tambah helper truncate_diff untuk membatasi panjang diff" -``` - ---- - -### Task 3: Embed diff in `edit` tool - -**Files:** -- Modify: `src/tool/fs/edit.rs` - -**Interfaces:** -- Consumes: `helpers::truncate_diff` (Task 2), `similar::TextDiff::from_lines`, `.unified_diff().context_radius(3).header(a, b)` (Display). -- Produces: `edit`'s success message now contains a ` ```diff ` fenced block instead of a byte-delta note. - -- [ ] **Step 1: Write the failing tests** - -Add at the end of `src/tool/fs/edit.rs` (there is no existing test module in this file): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx { - crate::tool::ToolCtx::builder().workspaces(vec![workspace]).build() - } - - fn temp_workspace() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("zesdex-edit-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn edit_returns_a_diff_block_for_a_single_replace() { - let workspace = temp_workspace(); - fs::write(workspace.join("a.txt"), "line1\nline2\nline3\n").unwrap(); - let ctx = test_ctx(workspace.clone()); - let args = json!({ - "path": "a.txt", - "old": "line2", - "new": "changed", - "reason": "test edit" - }); - let result = Edit.run(&ctx, &args).unwrap(); - assert!(result.contains("```diff")); - assert!(result.contains("-line2")); - assert!(result.contains("+changed")); - fs::remove_dir_all(&workspace).ok(); - } - - #[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(); - fs::write(workspace.join("big.txt"), &old_content).unwrap(); - let ctx = test_ctx(workspace.clone()); - let args = json!({ - "path": "big.txt", - "old": &old_content, - "new": &new_content, - "reason": "test large replace" - }); - let result = Edit.run(&ctx, &args).unwrap(); - assert!(result.contains("more lines truncated")); - fs::remove_dir_all(&workspace).ok(); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test tool::fs::edit::tests` -Expected: FAIL — `edit_returns_a_diff_block_for_a_single_replace` fails because the current message contains "byte delta" not "```diff"; `edit_truncates_a_very_large_diff` fails the same way. - -- [ ] **Step 3: Add the `similar` import and `helpers` module import** - -In `src/tool/fs/edit.rs`, change line 12 from: - -```rust -use super::helpers::arg_str; -``` - -to: - -```rust -use super::helpers::{self, arg_str}; -use similar::TextDiff; -``` - -- [ ] **Step 4: Replace the byte-delta message with a diff block** - -Replace this block (currently lines 100-121): - -```rust - fs::write(&path, &new_content) - .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; - let bytes_diff = if new_content.len() > content.len() { - new_content.len() - content.len() - } else { - content.len() - new_content.len() - }; - // Notify the LSP server of the on-disk change so diagnostics stay fresh. - // Never fail the edit because of this — LSP errors are surfaced as a - // trailing annotation on the success message instead. - let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { - lsp.did_change_file(&path); - String::new() - } else { - String::new() - }; - if check_matches.is_empty() { - Ok(format!("edited {} ({} byte delta){}", rel, bytes_diff as isize, lsp_note)) - } else { - Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}{}", rel, bytes_diff as isize, check_matches.join(", "), lsp_note)) - } - } -} -``` - -with: - -```rust - 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) - ); - 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. - // Never fail the edit because of this — LSP errors are surfaced as a - // trailing annotation on the success message instead. - let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { - lsp.did_change_file(&path); - String::new() - } else { - String::new() - }; - 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(", "))) - } - } -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cargo test tool::fs::edit::tests` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/tool/fs/edit.rs -git commit -m "feat: Tampilkan unified diff pada hasil tool edit" -``` - ---- - -### Task 4: Embed diff in `write` tool (overwrite case) - -**Files:** -- Modify: `src/tool/fs/write.rs` - -**Interfaces:** -- Consumes: `helpers::truncate_diff` (Task 2), `similar::TextDiff` (same API as Task 3). -- Produces: `write`'s success message gets an appended ` ```diff ` block when overwriting an existing UTF-8 file; unchanged for new files or non-UTF-8 overwrites. - -- [ ] **Step 1: Write the failing tests** - -Add at the end of `src/tool/fs/write.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx { - crate::tool::ToolCtx::builder().workspaces(vec![workspace]).build() - } - - fn temp_workspace() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("zesdex-write-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn write_to_a_new_file_has_no_diff_block() { - let workspace = temp_workspace(); - let ctx = test_ctx(workspace.clone()); - let args = json!({"path": "new.txt", "content": "hello\n", "reason": "test new file"}); - let result = Write.run(&ctx, &args).unwrap(); - assert!(result.contains("wrote 6 bytes")); - assert!(!result.contains("```diff")); - fs::remove_dir_all(&workspace).ok(); - } - - #[test] - fn write_overwriting_an_existing_utf8_file_includes_a_diff_block() { - 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 result = Write.run(&ctx, &args).unwrap(); - assert!(result.contains("```diff")); - assert!(result.contains("-old content")); - assert!(result.contains("+new content")); - fs::remove_dir_all(&workspace).ok(); - } - - #[test] - fn write_overwriting_a_non_utf8_file_has_no_diff_block() { - let workspace = temp_workspace(); - fs::write(workspace.join("binary.dat"), [0xFFu8, 0xFE, 0xFD]).unwrap(); - let ctx = test_ctx(workspace.clone()); - let args = json!({"path": "binary.dat", "content": "now text\n", "reason": "test binary overwrite"}); - let result = Write.run(&ctx, &args).unwrap(); - assert!(!result.contains("```diff")); - assert!(result.contains("wrote")); - fs::remove_dir_all(&workspace).ok(); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test tool::fs::write::tests` -Expected: FAIL — `write_overwriting_an_existing_utf8_file_includes_a_diff_block` fails (no diff block exists yet); the other two pass already (current behavior already matches them), which is fine. - -- [ ] **Step 3: Add the `similar` import** - -In `src/tool/fs/write.rs`, change line 10 from: - -```rust -use super::helpers::arg_str; -``` - -to: - -```rust -use super::helpers::{self, arg_str}; -use similar::TextDiff; -``` - -- [ ] **Step 4: Capture old content and append a diff block** - -Replace this block (currently lines 60-81): - -```rust - let path = resolve_path(&ctx.workspaces, &rel)?; - if let Some(parent) = path.parent() { - 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}"))?; - // Notify the LSP server of the on-disk change so diagnostics stay in - // sync. Never fails the write itself: a lock failure or LSP error is - // folded into the returned message instead of propagated as an Err. - let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { - lsp.did_change_file(&path); - String::new() - } else { - String::new() - }; - if check_matches.is_empty() { - Ok(format!("wrote {} bytes to {}{}", content.len(), rel, lsp_note)) - } else { - Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}", content.len(), rel, lsp_note, check_matches.join(", "))) - } - } -} -``` - -with: - -```rust - let path = resolve_path(&ctx.workspaces, &rel)?; - let old_content = fs::read_to_string(&path).ok(); - if let Some(parent) = path.parent() { - 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}"))?; - // Notify the LSP server of the on-disk change so diagnostics stay in - // sync. Never fails the write itself: a lock failure or LSP error is - // folded into the returned message instead of propagated as an Err. - let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { - lsp.did_change_file(&path); - String::new() - } else { - String::new() - }; - // Only emit a diff when the file existed before and was valid UTF-8; - // new files and binary overwrites fall back to the byte-count message. - let diff_note = if let Some(old) = old_content { - 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) - ); - 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)) - } else { - Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}{}", content.len(), rel, lsp_note, check_matches.join(", "), diff_note)) - } - } -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cargo test tool::fs::write::tests` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/tool/fs/write.rs -git commit -m "feat: Tampilkan unified diff saat tool write menimpa file yang sudah ada" -``` - ---- - -### Task 5: Diff-aware coloring in `markdown.rs` - -**Files:** -- Modify: `src/view/markdown.rs` - -**Interfaces:** -- Consumes: `Theme::SUCCESS`, `Theme::ERROR`, `Theme::INFO`, `Theme::TEXT_DIM`, `Theme::ACCENT_TEAL`, `Theme::CODE_BG` (all exist in `src/view/theme.rs`). -- Produces: `pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec>` (signature change — was `(text: &str, width: u16)`). Diff lines (` ```diff ` fenced blocks) keep their color even when `dim = true`; every other span falls back to `Theme::TEXT_DIM` + italic when `dim = true`, and is unchanged from today's behavior when `dim = false`. - -- [ ] **Step 1: Write the failing tests** - -Add at the end of `src/view/markdown.rs` (there is no existing test module in this file): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn span_text(spans: &[Span<'static>]) -> String { - spans.iter().map(|s| s.content.as_ref()).collect() - } - - #[test] - fn dim_false_plain_text_has_no_color() { - let spans = render_markdown("hello world", 0, false); - assert_eq!(span_text(&spans), "hello world"); - assert_eq!(spans[0].style, Style::default()); - } - - #[test] - fn dim_true_plain_text_is_dim_italic() { - let spans = render_markdown("hello", 0, true); - let expected = Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC); - assert_eq!(spans[0].style, expected); - } - - #[test] - fn dim_true_diff_lines_keep_their_own_color() { - let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```"; - let spans = render_markdown(md, 0, true); - let plus_span = spans.iter().find(|s| s.content.contains("+new line")).expect("plus span present"); - assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS)); - let minus_span = spans.iter().find(|s| s.content.contains("-old line")).expect("minus span present"); - assert_eq!(minus_span.style.fg, Some(Theme::ERROR)); - let hunk_span = spans.iter().find(|s| s.content.contains("@@")).expect("hunk header span present"); - assert_eq!(hunk_span.style.fg, Some(Theme::INFO)); - } - - #[test] - fn dim_true_non_diff_code_block_is_dimmed() { - let md = "```rust\nfn main() {}\n```"; - let spans = render_markdown(md, 0, true); - let code_span = spans.iter().find(|s| s.content.contains("fn main")).expect("code span present"); - assert_eq!(code_span.style, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test view::markdown::tests` -Expected: FAIL to compile — `render_markdown` takes 2 arguments, not 3, at every call site in the new tests. - -- [ ] **Step 3: Add the `apply_dim` and `diff_line_style` helpers** - -Add these two functions right above `pub fn render_markdown` in `src/view/markdown.rs`: - -```rust -/// Apply the "tool output" dim/italic style, or pass `style` through -/// unchanged, depending on `dim`. -fn apply_dim(style: Style, dim: bool) -> Style { - if dim { - Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC) - } else { - style - } -} - -/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff -/// prefix, returning the color it should always render with (even when the -/// surrounding tool output is dimmed) — or `None` for context lines and the -/// `+++`/`---` file-header lines, which use the normal code-block color. -fn diff_line_style(line: &str) -> Option