diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f991504a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Normalize all text files to LF in the repository. +# Working trees check out LF on every platform to keep diffs clean. +* text=auto eol=lf + +# Shell scripts must always be LF. +*.sh text eol=lf + +# Binary assets — never normalize or diff as text. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.pdf binary +*.gz binary +*.zip binary +*.wasm binary diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c39a1ae..f60b725af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### UI + +- **TUI restyled toward Claude Code's visual language.** Warm terracotta + accent (`#D97757`) replaces DeepSeek blue as the primary accent + (composer border + `>` prompt, action bullets, headings, live tool + markers), with a darker terracotta substituted in light mode for + contrast. Assistant actions and completed tools now lead with the `⏺` + bullet; tool results hang under a `⎿` tree connector. The composer is a + rounded box with a terracotta `> ` prompt (wrap math and cursor + position track the prompt gutter). The startup empty state is a rounded + `✻ Welcome to DeepSeek TUI` banner showing model + cwd + quick hints. + +### Added + +- **`/review` effort tiers.** `/review [--effort low|medium|high|max] ` + (a bare leading level also works, e.g. `/review high file.rs`) injects a depth + directive into the review skill — low/medium report few high-confidence + findings, high broadens coverage with flagged uncertainty, max is exhaustive. + Mirrors Claude Code's `/code-review` levels. Defaults to medium. + +- **Output styles (`concise`, `explanatory`).** The personality overlay + system is generalized toward Claude Code's output styles: alongside the + tone-shaping Calm/Playful, two behavior-shaping styles ship — `concise` + (answer-first, minimal) and `explanatory` (teaches as it works) — plus a + `Personality::from_str`/`as_str` API with aliases. Composed into the prompt + and tested. (Runtime selection of a non-default style via config/picker is a + pre-existing dormant gap — even Playful isn't yet user-selectable — and lands + separately.) + +- **File-defined sub-agent roles (`~/.deepseek/agents/.md`).** Drop a + Markdown file with YAML-ish frontmatter (`name`, `description`, optional + `model`/`tools`) plus a body, and that body becomes the sub-agent's system + prompt when the matching name is spawned as the `subagent_type` or `role` — + overriding the built-in role intro while still appending the shared output + contract. Brings Claude Code's file-defined agents to DeepSeek TUI on top of + the existing built-in roles (general/explore/plan/review/implementer/ + verifier). New `agents` module with a loader, lister, and context renderer. + +- **`memory-consolidate` bundled skill** — a reflective maintenance pass + over the user memory file: classify entries, merge duplicates, fix + contradicted facts, prune stale one-offs, and keep the file short + enough to stay useful on every turn. Auto-installed alongside + `skill-creator` and triggered when the user asks to consolidate/clean + up memory. The system-skill installer now bundles a *list* of skills + with a recorded known-set marker, so newly-bundled skills install on + upgrade while user-deleted skills stay deleted. +- **Four new lifecycle hook events — `stop`, `subagent_stop`, + `pre_compact`, `notification`** — bringing the hook surface in line + with Claude Code's most-used lifecycle hooks. `stop` fires when the + assistant finishes a turn (with `DEEPSEEK_TURN_STATUS` = + `completed`/`interrupted`/`failed`), the natural place to hang + auto-format, auto-test, or a custom notifier. `subagent_stop` fires + when a sub-agent completes. `pre_compact` fires as context + compaction begins (manual `/compact` or auto), before the summary + replaces the transcript — use it to snapshot first. `notification` + mirrors user-facing desktop notifications to external notifiers. + All are configurable via `[[hooks.hooks]]` and listed by `/hooks`. + +### Documentation + +- **`CLAUDE_CODE_GAP_ANALYSIS.md`** — a grounded comparison of the + agent against the latest Claude Code across prompts, tools, + skills/subagents, and lifecycle, with a prioritized modernization + roadmap. + +### Changed + +- **System prompt: behavioral alignment with current Claude Code + norms** — added Engineering Altitude (solve at the right level; no + over/under-engineering), Acting Safely (confirm irreversible & + outward-facing actions; look before overwrite), Editing Code: Fit In + (match surrounding style, reuse first, no drive-by churn), + clickable `path:line`/PR references, scheduling discipline, and + sharper plan-mode etiquette. Additions land above the locale closer + and keep the prompt byte-stable for unchanged workspaces. + +### Repository + +- **Enforce LF line endings via `.gitattributes`** — the working tree + had drifted to CRLF on every text file, rendering diffs unreviewable + (467 files of pure line-ending noise). Diffs now reflect real + changes only. + ## [0.8.32] - 2026-05-12 A "more useful tools" release. v0.8.31 made the tool surface diff --git a/CLAUDE_CODE_GAP_ANALYSIS.md b/CLAUDE_CODE_GAP_ANALYSIS.md new file mode 100644 index 000000000..7bc101886 --- /dev/null +++ b/CLAUDE_CODE_GAP_ANALYSIS.md @@ -0,0 +1,212 @@ +# DeepSeek TUI vs. Claude Code (latest) — Gap Analysis & Modernization Plan + +> Date: 2026-06-02 · Baseline: DeepSeek TUI v0.8.32 · Reference: Claude Code (latest, 2026) +> Scope: behavioral prompts, tools/capabilities, skills/subagents, lifecycle (hooks/memory). + +## TL;DR + +DeepSeek TUI is **not** a greenfield port — it is already a feature-rich Claude Code +analog built on the codex-rs architecture. The bulk of "Claude Code class" capability +is present: ~50 tools, 28 built-in slash-command modules plus user commands, a +`SKILL.md`-format skills system, an 8-event hook system, MCP client **and** server, +sub-agents with `fork_context`, plan/agent/yolo modes, approval policies, statusline, +checkpoints/undo, scheduling (`automation_*`), goal mode, share, and memory. + +So "modernize to match latest Claude Code" is **not** about adding the big primitives — +they exist. It is about **closing the long tail**: a handful of lifecycle hooks, a more +structured memory model, named/file-defined agent types, richer output styles, and a set +of **behavioral-prompt** refinements that bring the agent's *defaults* in line with +current Claude Code norms (altitude, irreversible-action confirmation, faithful +reporting, schedule/loop discipline). The prompt work is the highest leverage-to-risk +ratio and should land first. + +Legend — Effort: **S** (hours) · **M** (1–3 days) · **L** (week+). Risk reflects blast +radius in a ~210k-LOC production codebase. + +--- + +## 1. Behavioral prompts & agent loop ← highest leverage, lowest risk + +`crates/tui/src/prompts/base.md` (217 lines) is mature. It already absorbed the +`PROMPT_ANALYSIS.md` recommendations (RLM patterns, Sub-Agent Strategy, Parallel-First +Heuristic, Verification Principle, Composition Pattern, bumped thinking budget). The +remaining gaps are *current* Claude Code behavioral norms not yet encoded: + +| # | Claude Code norm | Present? | Gap | Effort/Risk | +|---|---|---|---|---| +| 1.1 | **Altitude** — solve at the right level of abstraction; don't over-fit to the literal ask or over-engineer | partial (decomposition only) | No explicit "right altitude / don't over-engineer / match the request's scope" guidance | S / low | +| 1.2 | **Confirm hard-to-reverse & outward-facing actions** (deletes, force-push, publishing to external services, sending messages) unless durably authorized | partial (`approvals/auto.md` mentions "pause before destructive") | Not a first-class principle in `base.md`; no "publishing is irreversible / approval doesn't transfer across contexts" framing | S / low | +| 1.3 | **Match surrounding code style** (comment density, naming, idiom) | no | Not stated; agent may impose its own style | S / low | +| 1.4 | **Look before you overwrite/delete** — inspect the target; if it contradicts how it was described, surface that instead of proceeding | no | Missing safety reflex | S / low | +| 1.5 | **Scheduling discipline** — when to *offer* `/automation` (`/schedule`/`/loop` analog) vs. just finish the work now | no | Scheduling tools exist but no guidance on *when* to reach for them; risk of over-offering | S / low | +| 1.6 | **Clickable references** — render `file:line` and PR/issue refs as links | partial (OSC-8 in TUI, #374) | Prompt doesn't instruct the model to *emit* `path:line` consistently | S / low | +| 1.7 | **Plan-mode etiquette** — investigate fully, then present a plan for approval before editing; don't ask "is the plan ready?" | partial (`modes/plan.md` is terse) | Could mirror Claude Code's ExitPlanMode discipline more explicitly | S / low | + +**Recommendation:** a single surgical pass on `base.md` + `modes/*.md` + `approvals/*.md`. +All additive, prefix-cache-aware (append below volatile boundary), independently +testable via the existing snapshot tests (`crates/tui-core/tests/snapshot.rs`, +`crates/tui/src/prompts.rs`). **Do this first.** + +--- + +## 2. Lifecycle: hooks + +`crates/tui/src/hooks.rs` supports 8 events: `SessionStart`, `SessionEnd`, +`MessageSubmit`, `ToolCallBefore`, `ToolCallAfter`, `ModeChange`, `OnError`, `ShellEnv`. + +Mapping to Claude Code's hook set: + +| Claude Code hook | DeepSeek equivalent | Status | +|---|---|---| +| SessionStart / SessionEnd | same | ✅ | +| UserPromptSubmit | `MessageSubmit` | ✅ | +| PreToolUse / PostToolUse | `ToolCallBefore` / `ToolCallAfter` | ✅ | +| Notification | — | ❌ missing | +| Stop (agent finished a response) | — | ❌ missing | +| SubagentStop | — | ❌ missing (sub-agent completion is an internal sentinel, not a user hook) | +| PreCompact | — | ❌ missing | +| (n/a) | `ModeChange`, `OnError`, `ShellEnv` | ➕ DeepSeek extras (keep) | + +**Gap:** no `PreCompact` (can't snapshot/guard before compaction), no `Stop`/`SubagentStop` +(can't trigger notifications, auto-format, or auto-test on turn completion), no +`Notification` event. The `Stop` hook in particular is the most-used Claude Code hook +(desktop notifications, auto-lint). + +**Recommendation:** add `PreCompact`, `Stop`, `SubagentStop`, `Notification` variants to +`HookEvent` and wire dispatch sites. The enum + config + executor are already factored, +so this is mostly wiring. **Effort: M / Risk: medium** (touches the agent loop and +compaction path). + +--- + +## 3. Lifecycle: memory + +Today: `note` tool + `crates/tui/src/memory.rs` + `/memory` command + `tools/remember.rs` ++ `tools/recall_archive.rs`. Functional, but free-form. + +Claude Code latest uses a **structured** file-based memory: one fact per file under a +`memory/` dir, each with frontmatter (`name`, `description`, `metadata.type ∈ +{user,feedback,project,reference}`), a `MEMORY.md` index (one line per memory, loaded +each session), `[[wikilink]]` cross-references, a recall-relevance discipline, and a +`consolidate-memory` skill to dedupe/prune. + +| Aspect | DeepSeek | Claude Code | Gap | +|---|---|---|---| +| Storage | free-form notes | one-fact-per-file + frontmatter | structure | +| Index | — | `MEMORY.md` loaded per session | no always-loaded index | +| Typing | — | user/feedback/project/reference | no taxonomy | +| Cross-links | — | `[[name]]` | none | +| Maintenance | — | `consolidate-memory` skill | none | +| Recall caveat | — | "memories reflect when written; verify before acting" | not encoded | + +**Recommendation:** evolve `memory.rs` toward the structured schema + a session-loaded +index, and ship a `memory-consolidate` bundled skill. **Effort: M / Risk: low** (additive; +keep `note` as the write path). + +--- + +## 4. Sub-agents & agent types + +Today: `agent_spawn`/`agent_result`/`agent_wait`/… with `fork_context`, +`subagent_output_format.md`, worktree support already present in +`tools/subagent/mod.rs` and `snapshot/`. Strong. + +Claude Code latest adds **named, file-defined agent types**: `.claude/agents/*.md` (and +bundled ones) with frontmatter (`name`, `description`, `tools`, `model`), surfaced to the +model as selectable `subagent_type`s with curated tool/model scoping, plus +`run_in_background` and "continue an existing agent via SendMessage." + +| Aspect | DeepSeek | Claude Code | Gap | +|---|---|---|---| +| Spawn ad-hoc child | ✅ | ✅ | — | +| `fork_context` / prefix-cache reuse | ✅ | ✅ (worktree) | — | +| **Named reusable agent types** (file-defined, tool/model-scoped) | ❌ | ✅ | library of roles (Explore, Plan, …) | +| Background agents | partial (`task_shell`) | ✅ (`run_in_background` agents) | distinct background *agents* | +| Continue an existing agent | `resume_agent` / `agent_send_input` | ✅ SendMessage | ~parity | +| Worktree isolation | ✅ present | ✅ | verify it's exposed to model | + +**Recommendation:** introduce `~/.deepseek/agents/*.md` (+ bundled `explore`, `plan`, +`code-review` agent defs) and let the model pick a `subagent_type`. **Effort: M / Risk: +low.** + +--- + +## 5. Skills + +Today: `SKILL.md` + frontmatter (ported from codex), `load_skill` tool, `/skills`, +bundled `skill-creator` + `v4-best-practices`. Format already matches Claude Code's. + +**Gaps vs. Claude Code latest:** +- **Plugin packaging** — Claude Code bundles skills+commands+agents+hooks+MCP as + installable *plugins* with a marketplace. DeepSeek has only minimal plugin refs. + (**Effort: L / Risk: medium** — defer.) +- **Skill discovery surfacing** — verify skills are advertised to the model with + descriptions for autonomous triggering (Claude Code injects the skill list each + session). Appears present via the `## Skills` prompt section; confirm. +- **Bundled library breadth** — Claude Code ships docx/pdf/pptx/xlsx and workflow skills. + DeepSeek ships 2. Optional content expansion. (**S–M, low risk.**) + +--- + +## 6. Output styles vs. personalities + +Today: `prompts/personalities/{calm,playful}.md` — *tone* overlays. + +Claude Code "output styles" are broader — they can re-shape the agent's whole operating +posture (e.g. explanatory/teaching modes, terse-vs-verbose, role changes), not just tone. + +**Gap:** only two, tone-scoped. **Recommendation:** generalize "personalities" into +"output styles" with a couple of behavior-shaping presets (e.g. `concise`, `explanatory`) +and user-definable styles under `~/.deepseek/styles/*.md`. **Effort: S–M / Risk: low.** + +--- + +## 7. Smaller UX/tool gaps + +| Item | Claude Code | DeepSeek | Note | +|---|---|---|---| +| Structured multi-choice question | `AskUserQuestion` (2–4 options, multi-select) | `request_user_input` (free-form) | add structured option UI — **S** | +| Out-of-scope task flagging | spawn-task chip | — | nice-to-have — **S** | +| Session chapters / ToC | mark-chapter + floating ToC | cycle dividers (#395) | partial; could add explicit chapters — **S** | +| Code-review effort levels | `/code-review low…ultra` | `/review` + `review` tool | add effort tiers — **S** | +| Deferred-tool search | ToolSearch | `tool_search_tool_regex/bm25` | ✅ parity | +| Background bash | `run_in_background` | `task_shell_start` | ✅ parity | +| Checkpoints/rewind | rewind | `/undo`, `revert_turn`, snapshots | ✅ parity | +| Web search/fetch | WebSearch/WebFetch | `web_search`/`fetch_url`/`web.run` | ✅ parity | + +--- + +## Prioritized roadmap + +**Wave 1 — Behavioral prompt alignment (S, low risk) — DO FIRST.** +Section 1: altitude, irreversible/outward-facing confirmation, match-surrounding-style, +look-before-overwrite, scheduling discipline, clickable refs, plan-mode etiquette. +Verifiable via existing prompt snapshot tests. One reviewable commit. + +**Wave 2 — Lifecycle hooks (M, medium).** Add `Stop`, `SubagentStop`, `PreCompact`, +`Notification` to `HookEvent` + dispatch. Biggest single capability gain (auto-test/ +auto-format/notify on completion). + +**Wave 3 — Structured memory + consolidate skill (M, low).** Schema + `MEMORY.md` index + +`memory-consolidate` skill. + +**Wave 4 — Named agent types (M, low).** `~/.deepseek/agents/*.md` + bundled roles. + +**Wave 5 — Output styles generalization (S–M, low).** Behavior-shaping presets + +user-defined styles. + +**Wave 6 — Smaller UX (S each).** Structured AskUserQuestion, review effort tiers, +explicit chapters, out-of-scope flagging. + +**Deferred — Plugin/marketplace system (L).** Largest, lowest marginal value given skills/ +commands/agents already work individually. + +--- + +## What NOT to change + +- The codex-derived crate architecture — sound and well-factored. +- DeepSeek-specific prompt content (V4 characteristics, prefix-cache economics, RLM, + language-mirroring rules) — correct and tailored; not Claude Code's to dictate. +- DeepSeek-only hook extras (`ModeChange`, `OnError`, `ShellEnv`) — keep. +- Terminal-output formatting guidance — terminal constraints are real. diff --git a/crates/tui/assets/skills/memory-consolidate/SKILL.md b/crates/tui/assets/skills/memory-consolidate/SKILL.md new file mode 100644 index 000000000..ce3221124 --- /dev/null +++ b/crates/tui/assets/skills/memory-consolidate/SKILL.md @@ -0,0 +1,63 @@ +--- +name: memory-consolidate +description: Reflective maintenance pass over the user memory file — merge duplicate facts, fix stale or contradicted information, drop obsolete entries, and keep the file short enough to stay useful on every turn. Use when the user asks to "consolidate memory", "clean up memory", "tidy my notes", or when the memory file has grown long, repetitive, or contradictory. +metadata: + short-description: Merge, fix, and prune the user memory file +allowed-tools: read_file, edit_file, write_file, exec_shell +--- + +# Memory Consolidate + +The user memory file is loaded into the system prompt on every turn (wrapped in a +`` block). It earns its place only if it stays short, current, and +non-redundant. This skill is a periodic reflective pass that keeps it that way. + +## When to run + +- The user explicitly asks to consolidate / clean up / tidy memory. +- The memory file has grown long (many bullets), repetitive, or self-contradictory. +- You just added several related entries and want to fold them together. + +Do **not** run this unprompted in the middle of unrelated work — it rewrites a file +the user owns. Mention you're about to consolidate, then do it. + +## Procedure + +1. **Locate the file.** Run `/memory` to see the resolved path (default + `~/.deepseek/memory.md`, overridable via `memory_path` in `config.toml` or + `DEEPSEEK_MEMORY_PATH`). Read it with `read_file`. + +2. **Classify every entry.** For each bullet, decide which it is: + - **Durable preference** — how the user wants you to behave (style, tooling, language). + - **Project fact** — stable truth about a repo or environment. + - **Reference** — a path, command, URL, or identifier worth remembering. + - **Stale / one-off** — true only at a past moment, already resolved, or since contradicted. + +3. **Merge duplicates.** Collapse bullets that say the same thing into one clear line. + When two entries conflict, keep the one supported by the most recent evidence and + drop the other — don't keep both. + +4. **Fix what's wrong.** If an entry contradicts what you now know to be true + (a renamed file, a changed preference, a corrected fact), update it. Memory records + what was believed when it was written, not ground truth — verify before trusting. + +5. **Prune.** Delete stale one-offs and anything no longer useful. A short, trusted + file beats a long, half-stale one. Aim to keep it tight. + +6. **Preserve the format.** Keep the existing Markdown bullet style. Retain the + `- (YYYY-MM-DD HH:MM UTC) …` timestamp prefix on entries that have one; for merged + entries, keep the most recent timestamp. Group related bullets under short `##` + headings if that improves scannability, but don't over-structure a small file. + +7. **Write it back** with `write_file` (full rewrite) or `edit_file` (surgical), + then **show the user a brief diff summary**: how many entries before/after, what you + merged, what you dropped, and anything you changed because it was contradicted. + +## Guardrails + +- Never invent facts. Only consolidate what is already there or what you can verify. +- Never remove a durable preference just because it's old — age is not staleness. +- If you're unsure whether an entry is stale, keep it and flag it to the user rather + than deleting silently. +- Rewriting memory busts the prompt's prefix cache for the next turn — that's expected + and worth it; don't avoid the cleanup to save a cache hit. diff --git a/crates/tui/src/agents.rs b/crates/tui/src/agents.rs new file mode 100644 index 000000000..9fdccf6a0 --- /dev/null +++ b/crates/tui/src/agents.rs @@ -0,0 +1,287 @@ +//! File-defined sub-agent role definitions. +//! +//! Claude Code lets users define reusable sub-agent *types* as Markdown files +//! with YAML-ish frontmatter. This module brings the same to DeepSeek TUI: +//! drop a `.md` into `~/.deepseek/agents/` (path mirrors `skills/`) and +//! its body becomes the sub-agent's system prompt when that name is spawned — +//! either as the `subagent_type` or via the `role` field. A matching file +//! overrides the built-in role intro; the shared output contract is still +//! appended by the caller, so file-defined agents stay well-behaved. +//! +//! Format: +//! ```text +//! --- +//! name: code-review +//! description: Reviews a diff for correctness and risk. +//! model: deepseek-v4-pro # optional +//! tools: read_file, grep_files # optional, comma-separated +//! --- +//! +//! ``` + +use std::path::{Path, PathBuf}; + +/// A parsed agent definition from `.md`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentDefinition { + pub name: String, + pub description: String, + pub model: Option, + pub tools: Option>, + /// The system-prompt body (everything after the frontmatter). + pub body: String, +} + +/// Resolve the default agents directory: `~/.deepseek/agents`. +/// +/// Honors `HOME` / `USERPROFILE` first (matching the rest of the config +/// resolution) before falling back to the platform home dir. +#[must_use] +pub fn default_agents_dir() -> Option { + home_dir().map(|home| home.join(".deepseek").join("agents")) +} + +fn home_dir() -> Option { + for var in ["HOME", "USERPROFILE"] { + if let Some(p) = std::env::var_os(var) { + let p = PathBuf::from(p); + if !p.as_os_str().is_empty() { + return Some(p); + } + } + } + dirs::home_dir() +} + +/// Parse a definition from raw file contents. `name_hint` (the file stem) is +/// used when the frontmatter omits an explicit `name`. Returns `None` when the +/// content has no usable body. +#[must_use] +pub fn parse_definition(name_hint: &str, raw: &str) -> Option { + let (frontmatter, body) = split_frontmatter(raw); + + let mut name = name_hint.trim().to_string(); + let mut description = String::new(); + let mut model = None; + let mut tools = None; + + for line in frontmatter.lines() { + let line = line.trim(); + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key.trim().to_ascii_lowercase(); + let value = value.trim().trim_matches('"').trim_matches('\'').trim(); + if value.is_empty() { + continue; + } + match key.as_str() { + "name" => name = value.to_string(), + "description" => description = value.to_string(), + "model" => model = Some(value.to_string()), + "tools" | "allowed-tools" | "allowed_tools" => { + let list: Vec = value + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + if !list.is_empty() { + tools = Some(list); + } + } + _ => {} + } + } + + let body = body.trim(); + if body.is_empty() || name.is_empty() { + return None; + } + + Some(AgentDefinition { + name, + description, + model, + tools, + body: body.to_string(), + }) +} + +/// Split `raw` into `(frontmatter, body)`. When there is no `---`-delimited +/// frontmatter, the whole input is the body. +fn split_frontmatter(raw: &str) -> (&str, &str) { + let trimmed = raw.trim_start_matches('\u{feff}'); // tolerate a BOM + let rest = match trimmed + .strip_prefix("---\n") + .or_else(|| trimmed.strip_prefix("---\r\n")) + { + Some(rest) => rest, + None => return ("", trimmed), + }; + // Find the closing `---` on its own line. + if let Some((fm_end, line_end)) = find_closing_fence(rest) { + (&rest[..fm_end], &rest[line_end..]) + } else { + ("", trimmed) + } +} + +/// Returns `(start_of_fence, end_of_fence_line)` byte offsets for the closing +/// `---` line within `s`, or `None`. +fn find_closing_fence(s: &str) -> Option<(usize, usize)> { + let mut offset = 0; + for line in s.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']).trim(); + if trimmed == "---" { + return Some((offset, offset + line.len())); + } + offset += line.len(); + } + None +} + +/// Load a named definition from `dir`. Matching is case-insensitive on the +/// file stem (`code-review` matches `Code-Review.md`). +#[must_use] +pub fn load_agent_definition(dir: &Path, name: &str) -> Option { + let want = name.trim().to_ascii_lowercase(); + if want.is_empty() { + return None; + } + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if stem == want { + let raw = std::fs::read_to_string(&path).ok()?; + return parse_definition(&stem, &raw); + } + } + None +} + +/// List every definition in `dir`, sorted by name. Unreadable or malformed +/// files are skipped. +#[must_use] +pub fn list_agent_definitions(dir: &Path) -> Vec { + let mut defs = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return defs; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + if let Ok(raw) = std::fs::read_to_string(&path) + && let Some(def) = parse_definition(&stem, &raw) + { + defs.push(def); + } + } + defs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + defs +} + +/// Render an "Available Agents" context block for the system prompt, or `None` +/// when no definitions exist. Mirrors the skills-context shape. +#[must_use] +pub fn render_available_agents_context(dir: &Path) -> Option { + let defs = list_agent_definitions(dir); + if defs.is_empty() { + return None; + } + let mut out = String::from( + "## Available Agents\n\nUser-defined sub-agent roles (spawn via `agent_spawn` with the matching `subagent_type`/`role`):\n", + ); + for def in &defs { + let desc = if def.description.is_empty() { + String::new() + } else { + format!(" — {}", def.description) + }; + out.push_str(&format!("- `{}`{desc}\n", def.name)); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + const SAMPLE: &str = "---\nname: code-review\ndescription: Reviews a diff.\nmodel: deepseek-v4-pro\ntools: read_file, grep_files\n---\nYou are a meticulous code reviewer. Focus on correctness and risk."; + + #[test] + fn parses_frontmatter_and_body() { + let def = parse_definition("code-review", SAMPLE).expect("parse"); + assert_eq!(def.name, "code-review"); + assert_eq!(def.description, "Reviews a diff."); + assert_eq!(def.model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + def.tools, + Some(vec!["read_file".to_string(), "grep_files".to_string()]) + ); + assert!(def.body.starts_with("You are a meticulous code reviewer")); + } + + #[test] + fn body_without_frontmatter_is_kept() { + let def = parse_definition("plain", "Just a body, no frontmatter.").expect("parse"); + assert_eq!(def.name, "plain"); + assert_eq!(def.body, "Just a body, no frontmatter."); + assert!(def.description.is_empty()); + } + + #[test] + fn empty_body_is_rejected() { + assert!(parse_definition("x", "---\nname: x\n---\n ").is_none()); + } + + #[test] + fn loads_by_case_insensitive_stem() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Code-Review.md"), SAMPLE).unwrap(); + let def = load_agent_definition(dir.path(), "code-review").expect("load"); + assert_eq!(def.name, "code-review"); + assert!(load_agent_definition(dir.path(), "missing").is_none()); + } + + #[test] + fn lists_and_renders_context_sorted() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("code-review.md"), SAMPLE).unwrap(); + fs::write( + dir.path().join("explore.md"), + "---\nname: explore\ndescription: Read-only scout.\n---\nExplore the codebase.", + ) + .unwrap(); + let defs = list_agent_definitions(dir.path()); + assert_eq!(defs.len(), 2); + assert_eq!(defs[0].name, "code-review"); // sorted + assert_eq!(defs[1].name, "explore"); + + let ctx = render_available_agents_context(dir.path()).expect("context"); + assert!(ctx.contains("`code-review` — Reviews a diff.")); + assert!(ctx.contains("`explore` — Read-only scout.")); + } + + #[test] + fn empty_dir_renders_no_context() { + let dir = tempdir().unwrap(); + assert!(render_available_agents_context(dir.path()).is_none()); + } +} diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index fbf7d760b..428095b4a 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -63,6 +63,22 @@ fn events() -> CommandResult { HookEvent::OnError, "fires on transport / capacity / tool errors", ), + ( + HookEvent::Stop, + "fires when the assistant finishes a turn (DEEPSEEK_TURN_STATUS = completed/interrupted/failed)", + ), + ( + HookEvent::SubagentStop, + "fires when a sub-agent finishes (DEEPSEEK_MESSAGE carries id + result summary)", + ), + ( + HookEvent::PreCompact, + "fires as context compaction begins (manual /compact or auto)", + ), + ( + HookEvent::Notification, + "fires when a user-facing notification is surfaced (DEEPSEEK_MESSAGE)", + ), ]; for (event, desc) in ordered { out.push_str(&format!(" - `{}` — {desc}\n", event_label(event))); @@ -139,6 +155,10 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", HookEvent::ShellEnv => "shell_env", + HookEvent::Stop => "stop", + HookEvent::SubagentStop => "subagent_stop", + HookEvent::PreCompact => "pre_compact", + HookEvent::Notification => "notification", } } @@ -298,6 +318,10 @@ mod tests { assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); + assert_eq!(event_label(HookEvent::Stop), "stop"); + assert_eq!(event_label(HookEvent::SubagentStop), "subagent_stop"); + assert_eq!(event_label(HookEvent::PreCompact), "pre_compact"); + assert_eq!(event_label(HookEvent::Notification), "notification"); } #[test] diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index a36164508..ce1e59746 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -469,7 +469,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "review", aliases: &[], - usage: "/review ", + usage: "/review [--effort low|medium|high|max] ", description_id: MessageId::CmdReviewDescription, }, CommandInfo { diff --git a/crates/tui/src/commands/review.rs b/crates/tui/src/commands/review.rs index c4c569fd9..d4b7cceaa 100644 --- a/crates/tui/src/commands/review.rs +++ b/crates/tui/src/commands/review.rs @@ -14,10 +14,67 @@ fn warnings_suffix(registry: &SkillRegistry) -> String { format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- ")) } +/// Review effort tier (Claude Code's `/code-review ` analog). Controls +/// how broadly the review hunts and whether it surfaces uncertain findings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewEffort { + Low, + Medium, + High, + Max, +} + +impl ReviewEffort { + fn from_token(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "medium" | "med" => Some(Self::Medium), + "high" => Some(Self::High), + "max" | "ultra" => Some(Self::Max), + _ => None, + } + } + + /// Directive appended to the review instruction, shaping breadth and the + /// confidence bar for what gets reported. + fn directive(self) -> &'static str { + match self { + Self::Low => "Review effort: low — report only the few highest-confidence, clearly correct findings. Skip style nits and speculative issues.", + Self::Medium => "Review effort: medium — report high-confidence correctness bugs and clear cleanups. Keep findings focused; avoid speculation.", + Self::High => "Review effort: high — broaden coverage across the diff and adjacent code paths; you may include lower-confidence findings, clearly flagged as uncertain.", + Self::Max => "Review effort: max — be exhaustive. Trace edge cases, error paths, and concurrency; surface even low-confidence findings, each labelled with your confidence.", + } + } +} + +/// Split `/review` args into an optional leading effort token and the target. +/// Accepts `--effort high `, `--effort=high `, or a bare +/// leading `high `. Defaults to Medium when no level is given. +fn parse_effort_and_target(args: &str) -> (ReviewEffort, &str) { + let args = args.trim(); + // `--effort ` or `--effort= ` + if let Some(rest) = args.strip_prefix("--effort") { + let rest = rest.trim_start_matches('=').trim_start(); + let (level_tok, target) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + if let Some(effort) = ReviewEffort::from_token(level_tok) { + return (effort, target.trim()); + } + } + // Bare leading level: `high ` + if let Some((first, rest)) = args.split_once(char::is_whitespace) + && let Some(effort) = ReviewEffort::from_token(first) + { + return (effort, rest.trim()); + } + (ReviewEffort::Medium, args) +} + pub fn review(app: &mut App, args: Option<&str>) -> CommandResult { - let target = args.unwrap_or("").trim(); + let (effort, target) = parse_effort_and_target(args.unwrap_or("")); if target.is_empty() { - return CommandResult::error("Usage: /review "); + return CommandResult::error( + "Usage: /review [--effort low|medium|high|max] ", + ); } let skills_dir = app.skills_dir.clone(); @@ -50,12 +107,17 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult { }; let instruction = format!( - "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.", - skill.name, skill.body + "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.", + skill.name, + skill.body, + effort.directive() ); app.add_message(HistoryCell::System { - content: format!("Activated skill: {}\n\n{}", skill.name, skill.description), + content: format!( + "Activated skill: {} ({:?} effort)\n\n{}", + skill.name, effort, skill.description + ), }); app.active_skill = Some(instruction); @@ -135,4 +197,53 @@ mod tests { assert!(app.active_skill.is_some()); assert!(!app.history.is_empty()); } + + #[test] + fn parse_effort_defaults_to_medium_and_keeps_target() { + let (effort, target) = parse_effort_and_target("src/lib.rs"); + assert_eq!(effort, ReviewEffort::Medium); + assert_eq!(target, "src/lib.rs"); + } + + #[test] + fn parse_effort_reads_bare_leading_level() { + let (effort, target) = parse_effort_and_target("high src/lib.rs"); + assert_eq!(effort, ReviewEffort::High); + assert_eq!(target, "src/lib.rs"); + } + + #[test] + fn parse_effort_reads_flag_forms_and_aliases() { + assert_eq!( + parse_effort_and_target("--effort max the diff"), + (ReviewEffort::Max, "the diff") + ); + assert_eq!( + parse_effort_and_target("--effort=low file.rs"), + (ReviewEffort::Low, "file.rs") + ); + // "ultra" aliases to Max. + assert_eq!( + parse_effort_and_target("ultra file.rs"), + (ReviewEffort::Max, "file.rs") + ); + } + + #[test] + fn parse_effort_treats_unknown_first_token_as_part_of_target() { + let (effort, target) = parse_effort_and_target("MyClass.method"); + assert_eq!(effort, ReviewEffort::Medium); + assert_eq!(target, "MyClass.method"); + } + + #[test] + fn review_injects_effort_directive_into_active_skill() { + let tmpdir = TempDir::new().unwrap(); + create_review_skill_dir(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = review(&mut app, Some("--effort high file.rs")); + assert!(matches!(result.action, Some(AppAction::SendMessage(_)))); + let active = app.active_skill.expect("active skill set"); + assert!(active.contains("Review effort: high")); + } } diff --git a/crates/tui/src/deepseek_theme.rs b/crates/tui/src/deepseek_theme.rs index 13f96cd00..f74d3b88e 100644 --- a/crates/tui/src/deepseek_theme.rs +++ b/crates/tui/src/deepseek_theme.rs @@ -101,7 +101,7 @@ impl Theme { tool_title_color: palette::LIGHT_TEXT_SOFT, tool_value_color: palette::LIGHT_TEXT_MUTED, tool_label_color: palette::LIGHT_TEXT_HINT, - tool_running_accent: palette::DEEPSEEK_BLUE, + tool_running_accent: palette::CLAUDE_TERRACOTTA, tool_success_accent: palette::LIGHT_TEXT_HINT, tool_failed_accent: palette::DEEPSEEK_RED, plan_progress_color: palette::DEEPSEEK_BLUE, diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 8ce934f53..315036875 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -46,6 +46,21 @@ pub enum HookEvent { /// fail or time out are logged but do *not* abort the shell call; they /// simply contribute no env vars. ShellEnv, + /// Triggered when the assistant finishes a turn (response complete, + /// interrupted, or failed). The Claude Code `Stop` analog — the most + /// common place to hang auto-format, auto-test, or a desktop notification. + /// `DEEPSEEK_TURN_STATUS` carries `completed` / `interrupted` / `failed`. + Stop, + /// Triggered when a sub-agent finishes. The `SubagentStop` analog. + SubagentStop, + /// Triggered as context compaction begins, before the summary replaces + /// the transcript — covers both manual `/compact` and auto-compaction. + /// The `PreCompact` analog; use it to snapshot or checkpoint first. + PreCompact, + /// Triggered when DeepSeek surfaces a user-facing notification (e.g. the + /// long-turn desktop notification). The `Notification` analog — route it + /// to your own notifier (Slack, ntfy, system tray). + Notification, } impl HookEvent { @@ -61,6 +76,10 @@ impl HookEvent { HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", HookEvent::ShellEnv => "shell_env", + HookEvent::Stop => "stop", + HookEvent::SubagentStop => "subagent_stop", + HookEvent::PreCompact => "pre_compact", + HookEvent::Notification => "notification", } } } @@ -251,6 +270,8 @@ pub struct HookContext { pub total_tokens: Option, /// Session cost in USD pub session_cost: Option, + /// Turn outcome for `Stop`: `completed` / `interrupted` / `failed` + pub turn_status: Option, } impl HookContext { @@ -328,6 +349,11 @@ impl HookContext { self } + pub fn with_turn_status(mut self, status: &str) -> Self { + self.turn_status = Some(status.to_string()); + self + } + /// Convert to environment variables pub fn to_env_vars(&self) -> HashMap { let mut env = HashMap::new(); @@ -398,6 +424,9 @@ impl HookContext { if let Some(cost) = self.session_cost { env.insert("DEEPSEEK_SESSION_COST".to_string(), format!("{cost:.6}")); } + if let Some(ref status) = self.turn_status { + env.insert("DEEPSEEK_TURN_STATUS".to_string(), status.clone()); + } env } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 02ba2e196..72de50aeb 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -13,6 +13,7 @@ use tempfile::NamedTempFile; use wait_timeout::ChildExt; mod acp_server; +mod agents; mod artifacts; mod audit; mod auto_reasoning; diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c2513c8a2..49261a03d 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -57,6 +57,24 @@ pub const DEEPSEEK_SLATE: Color = Color::Rgb( pub const DEEPSEEK_RED: Color = Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2); +// Claude Code-style warm terracotta accent. Used as the primary accent +// (composer border + `>` prompt, ⏺ action bullets, headings, selection) on an +// otherwise neutral text base — terracotta is a highlight, not the whole UI. +pub const CLAUDE_TERRACOTTA_RGB: (u8, u8, u8) = (217, 119, 87); // #D97757 +pub const CLAUDE_TERRACOTTA_SOFT_RGB: (u8, u8, u8) = (232, 155, 130); // #E89B82 +pub const CLAUDE_TERRACOTTA: Color = Color::Rgb( + CLAUDE_TERRACOTTA_RGB.0, + CLAUDE_TERRACOTTA_RGB.1, + CLAUDE_TERRACOTTA_RGB.2, +); +pub const CLAUDE_TERRACOTTA_SOFT: Color = Color::Rgb( + CLAUDE_TERRACOTTA_SOFT_RGB.0, + CLAUDE_TERRACOTTA_SOFT_RGB.1, + CLAUDE_TERRACOTTA_SOFT_RGB.2, +); +// Darker terracotta for inactive borders / subtle warm framing. +pub const CLAUDE_TERRACOTTA_DIM: Color = Color::Rgb(122, 74, 58); // #7A4A3A + pub const LIGHT_SURFACE: Color = Color::Rgb( LIGHT_SURFACE_RGB.0, LIGHT_SURFACE_RGB.1, @@ -110,7 +128,7 @@ pub const LIGHT_SELECTION_BG: Color = Color::Rgb( pub const TEXT_BODY: Color = Color::Rgb(226, 232, 240); // #E2E8F0 pub const TEXT_SECONDARY: Color = Color::Rgb(177, 190, 207); // #B1BECF pub const TEXT_HINT: Color = Color::Rgb(135, 151, 171); // #8797AB -pub const TEXT_ACCENT: Color = DEEPSEEK_SKY; +pub const TEXT_ACCENT: Color = CLAUDE_TERRACOTTA_SOFT; pub const SELECTION_TEXT: Color = Color::White; pub const TEXT_SOFT: Color = Color::Rgb(217, 226, 238); // #D9E2EE pub const TEXT_REASONING: Color = Color::Rgb(211, 170, 112); // #D3AA70 @@ -123,12 +141,11 @@ pub const USER_BODY: Color = Color::Rgb(74, 222, 128); // #4ADE80 green pub const LIGHT_USER_BODY: Color = Color::Rgb(21, 128, 61); // #15803D green // New semantic colors for UI theming -pub const BORDER_COLOR: Color = - Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2); +pub const BORDER_COLOR: Color = CLAUDE_TERRACOTTA_DIM; // warm inactive border #[allow(dead_code)] -pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5 +pub const ACCENT_PRIMARY: Color = CLAUDE_TERRACOTTA; // #D97757 #[allow(dead_code)] -pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2 +pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #E89B82 #[allow(dead_code)] pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30 #[allow(dead_code)] @@ -153,7 +170,7 @@ pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); // #123426 dark green t pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); // #34161C dark red tint pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); // #57C785 pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(224, 153, 72); // #E09948 -pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA +pub const ACCENT_TOOL_LIVE: Color = CLAUDE_TERRACOTTA; // #D97757 warm live bullet pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99 pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(191, 205, 220); // #BFCEDC @@ -343,7 +360,11 @@ pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> LIGHT_TEXT_SOFT } else if color == BORDER_COLOR { LIGHT_BORDER - } else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE { + } else if color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE { + // Terracotta accent (also ACCENT_PRIMARY, same RGB) → a darker + // terracotta so it stays legible on the light surface. + Color::Rgb(166, 75, 47) // #A64B2F + } else if color == DEEPSEEK_SKY { DEEPSEEK_BLUE } else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE { Color::Rgb(146, 64, 14) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 4028aa3d5..fd9f07082 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -339,9 +339,13 @@ idioma. A menos que o usuário peça explicitamente a troca (por exemplo, \ \"think in English\"), continue pensando e respondendo em português do \ Brasil."; -/// Personality overlays — voice and tone. +/// Output-style overlays — voice, tone, and behavioral posture. Calm and +/// Playful shape *tone*; Concise and Explanatory shape *how much* the agent +/// says and whether it teaches as it works (Claude Code's "output styles"). pub const CALM_PERSONALITY: &str = include_str!("prompts/personalities/calm.md"); pub const PLAYFUL_PERSONALITY: &str = include_str!("prompts/personalities/playful.md"); +pub const CONCISE_PERSONALITY: &str = include_str!("prompts/personalities/concise.md"); +pub const EXPLANATORY_PERSONALITY: &str = include_str!("prompts/personalities/explanatory.md"); /// Mode deltas — permissions, workflow expectations, mode-specific rules. pub const AGENT_MODE: &str = include_str!("prompts/modes/agent.md"); @@ -366,13 +370,17 @@ pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt"); // ── Personality selection ───────────────────────────────────────────── -/// Which personality overlay to apply. +/// Which output-style / personality overlay to apply. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Personality { /// Cool, spatial, reserved — the default. Calm, /// Warm, energetic, playful — alternative for fun mode. Playful, + /// Minimal, answer-first output for fluent, busy users. + Concise, + /// Teaches as it works — explains choices, trade-offs, and concepts. + Explanatory, } impl Personality { @@ -390,10 +398,38 @@ impl Personality { } } + /// Parse an output-style name (case-insensitive). Returns `None` for + /// unknown names so callers can fall back to the default. + #[must_use] + #[allow(dead_code)] // selection wiring (config/picker) lands separately + pub fn from_str(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "calm" | "default" => Some(Self::Calm), + "playful" | "fun" => Some(Self::Playful), + "concise" | "terse" | "minimal" => Some(Self::Concise), + "explanatory" | "explain" | "teaching" | "learning" => Some(Self::Explanatory), + _ => None, + } + } + + /// Stable identifier for config / display. + #[must_use] + #[allow(dead_code)] // selection wiring (config/picker) lands separately + pub fn as_str(self) -> &'static str { + match self { + Self::Calm => "calm", + Self::Playful => "playful", + Self::Concise => "concise", + Self::Explanatory => "explanatory", + } + } + fn prompt(self) -> &'static str { match self { Self::Calm => CALM_PERSONALITY, Self::Playful => PLAYFUL_PERSONALITY, + Self::Concise => CONCISE_PERSONALITY, + Self::Explanatory => EXPLANATORY_PERSONALITY, } } } @@ -1271,6 +1307,36 @@ mod tests { assert!(!calm.contains("Personality: Playful")); } + #[test] + fn output_styles_compose_distinct_overlays() { + let concise = compose_prompt(AppMode::Agent, Personality::Concise); + let explanatory = compose_prompt(AppMode::Agent, Personality::Explanatory); + assert!(concise.contains("Output Style: Concise")); + assert!(explanatory.contains("Output Style: Explanatory")); + assert!(!concise.contains("Output Style: Explanatory")); + } + + #[test] + fn personality_from_str_round_trips_and_aliases() { + assert_eq!(Personality::from_str("calm"), Some(Personality::Calm)); + assert_eq!(Personality::from_str("Concise"), Some(Personality::Concise)); + assert_eq!(Personality::from_str("terse"), Some(Personality::Concise)); + assert_eq!( + Personality::from_str("teaching"), + Some(Personality::Explanatory) + ); + assert_eq!(Personality::from_str("nope"), None); + // as_str round-trips through from_str. + for p in [ + Personality::Calm, + Personality::Playful, + Personality::Concise, + Personality::Explanatory, + ] { + assert_eq!(Personality::from_str(p.as_str()), Some(p)); + } + } + #[test] fn compact_template_is_included_in_full_prompt() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index dbb77dfda..3598a1fa5 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -54,6 +54,15 @@ Your default workflow for any non-trivial request: **Key principle**: make your work visible. The sidebar shows Plan / Todos / Tasks / Agents. When these panels are empty, the user has no idea what you're doing. Keep them populated. +## Engineering Altitude + +Solve the problem at the right level of abstraction — neither under nor over. + +- **Match the scope of the request.** A one-line fix is a one-line fix; don't turn it into a refactor. A "make it work" ask doesn't license a redesign. When you see a larger cleanup worth doing, name it separately rather than smuggling it into the current change. +- **Don't over-engineer.** Prefer the simplest design that satisfies the requirement and the codebase's existing patterns. Add abstraction only when a second concrete need exists, not in anticipation of one. +- **Don't under-solve.** If the literal ask would leave the user with a broken or half-working result, address the actual underlying need and say what you did. Read the request for intent, not just words. +- **Surface trade-offs, don't bury them.** When two reasonable approaches diverge on cost, risk, or scope, state the choice and your default rather than silently picking one. + ## Verification Principle After every tool call that produces a result you'll act on, verify before proceeding: @@ -74,6 +83,16 @@ When using tool results, preserve only the key facts needed for later reasoning If a tool call fails, inspect the error before retrying. Do not repeat the identical action blindly. Adjust the command, inputs, or approach based on the failure, and do not abandon a viable approach after a single recoverable failure. +## Acting Safely: Irreversible & Outward-Facing Actions + +Some actions are hard or impossible to undo. For these, confirm with the user first unless they've durably authorized the specific action, or explicitly told you to proceed without asking. Approval in one context does not extend to the next — a "yes" to one delete is not a yes to all deletes. + +- **Hard to reverse, local**: deleting or overwriting files you didn't create, `rm -rf`, `git reset --hard`, force-pushes, dropping database tables, history rewrites. Look at the target before you act. If what you find contradicts how it was described to you — a file that isn't what the user thinks, a branch with unexpected commits — stop and surface that instead of proceeding. +- **Outward-facing**: anything that leaves this machine — opening or commenting on PRs/issues, sending messages, posting to an external service, publishing a package, pushing to a remote. Sending content externally publishes it; it may be cached or indexed even if you later delete it. Treat these as requiring explicit intent. +- **When in doubt, narrate then pause.** State what you're about to do and why, and let the user confirm. A clear one-line heads-up costs less than an unrecoverable mistake. + +Report outcomes faithfully regardless: if a step was skipped or failed, say so plainly; when something is done and verified, state it plainly without hedging. + ## Composition Pattern for Multi-Step Work For any task estimated to take 5+ steps: @@ -186,6 +205,18 @@ Use `rlm` for long-context semantic work, bulk classification/extraction, and de Inside the `rlm` REPL, the sub-LLM has access to `llm_query()`, `llm_query_batched()`, `rlm_query()`, and `rlm_query_batched()` as Python helpers for further sub-LLM work — those are not standalone tools you call directly. +### `automation_*` (scheduling) +Most work just finishes — don't reach for scheduling by default. Use `automation_*` only when the work leaves a real future obligation with a concrete time or condition you can name: a recurring check the user asked for ("every morning…"), a deploy/CI run with an ETA to poll, a follow-up gated on a date. Don't schedule to poll for work the runtime will already notify you about, and don't offer to schedule unfinished scope — finish it now. If there's no concrete date, ETA, or trigger in the work itself, don't invent one. + +## Editing Code: Fit In + +Write code that reads like the code already around it. Before adding to a file, notice its conventions and match them: + +- **Style and idiom**: naming, formatting, error-handling patterns, comment density. Don't impose a personal style or a different paradigm on a file that has its own. The smallest diff that fits the surroundings is usually the right one. +- **Reuse before you add**: check whether a helper, type, or pattern already exists before writing a new one. Duplicated logic is a defect, not a feature. +- **Look before you overwrite.** Read the target before replacing or deleting it. If its contents contradict what you were told to expect, surface the discrepancy rather than blindly overwriting. +- **No drive-by churn**: don't reformat, rename, or "tidy" code unrelated to your change — it buries the real diff and breaks blame. Keep unrelated cleanups for a separate, named change. + ## Internal Sub-agent Completion Events When you spawn a sub-agent via `agent_spawn`, the child runs independently. The runtime may send you an internal `` completion event when it finishes. This event is not user input. It carries: @@ -215,3 +246,5 @@ You're rendering into a terminal, not a browser. Markdown tables almost never re - **Definition-style lists** (`- **Label**: value`) when the user asked for a comparison or summary. If you genuinely need column-aligned data (e.g. the user asked for a table or for `/cost` style output), keep columns narrow, ASCII-only, and limit to 2–3 columns. Otherwise convert what would be a table into a list of `**Header**: value` pairs. + +**References.** When you point at a location in the code, write it as `path/to/file.rs:42` — the TUI turns `path:line` into a clickable link, so the user can jump straight there. When you reference a PR or issue, include its number and, when known, the URL rather than a bare mention. Cite `web_search` results as `(ref_id)`. diff --git a/crates/tui/src/prompts/modes/plan.md b/crates/tui/src/prompts/modes/plan.md index 8b854a4f6..2158db33c 100644 --- a/crates/tui/src/prompts/modes/plan.md +++ b/crates/tui/src/prompts/modes/plan.md @@ -7,4 +7,10 @@ granular, verifiable steps. All writes and patches are blocked — you can read can't change it. Shell and code execution are unavailable. Use this mode to build a thorough plan. Spawn read-only sub-agents for parallel investigation. -When the plan is solid, the user will switch modes so you can execute. + +Investigate fully *before* you present a plan — read the actual code paths you intend to +change, don't plan from assumptions. When you've finished investigating, present the plan +as a concrete, ordered set of steps naming the specific files and changes involved, then +let the user approve and switch modes to execute. If a requirement is genuinely ambiguous +in a way that changes the plan, ask the user *during* planning — but don't ask "is the plan +ready?" or "should I proceed?"; presenting the plan is itself the request for approval. diff --git a/crates/tui/src/prompts/personalities/concise.md b/crates/tui/src/prompts/personalities/concise.md new file mode 100644 index 000000000..de41aa4ae --- /dev/null +++ b/crates/tui/src/prompts/personalities/concise.md @@ -0,0 +1,11 @@ +## Output Style: Concise + +Optimize for the fewest words that fully answer. The user is fluent and busy. + +- Lead with the answer or the result, then only the detail that earns its place. +- Skip preamble, restating the question, and "I'll now…" narration beyond the one-line action cue. +- Prefer a short sentence or a tight list over a paragraph. No summary of what you just said. +- Show code, paths, and commands directly; don't describe what a snippet does if it's self-evident. +- Drop hedging and filler ("basically", "essentially", "as you can see"). State it. +- One short confirmation when a task is done — not a recap of every step. +- Brevity never means dropping a real caveat: if something is risky or uncertain, say so in a clause, not a section. diff --git a/crates/tui/src/prompts/personalities/explanatory.md b/crates/tui/src/prompts/personalities/explanatory.md new file mode 100644 index 000000000..249f6c488 --- /dev/null +++ b/crates/tui/src/prompts/personalities/explanatory.md @@ -0,0 +1,10 @@ +## Output Style: Explanatory + +Teach as you work. The user wants to understand the codebase and your reasoning, not just receive a result. + +- When you make a non-obvious choice, add a brief "why": the trade-off, the constraint, the alternative you rejected. +- Surface insights about how the code works as you discover them — the data flow, the invariant, the gotcha — in a sentence or two, inline. +- Name the concept when one applies ("this is a classic N+1", "that's the borrow-checker's drop order") so the user can look it up. +- Keep explanations proportional: a clause for small choices, a short paragraph for architectural ones. Don't lecture or pad. +- Still do the work — explanation accompanies action, it doesn't replace it. Finish the task; don't stop at describing it. +- Prefer concrete grounding (this file, this line, this error) over abstract theory. diff --git a/crates/tui/src/skills/system.rs b/crates/tui/src/skills/system.rs index 96823888d..7f2b718a0 100644 --- a/crates/tui/src/skills/system.rs +++ b/crates/tui/src/skills/system.rs @@ -1,66 +1,128 @@ -//! System-skill installer: bundles skill-creator and auto-installs it on first launch. +//! System-skill installer: bundles skills and auto-installs them on first launch. +use std::collections::BTreeSet; use std::fs; use std::path::Path; -const BUNDLED_SKILL_VERSION: &str = "1"; +/// Bumped whenever any bundled skill's body changes so existing installs +/// upgrade in place. Newly *added* skills install on any version regardless, +/// driven by the recorded known-set in the marker (see [`parse_marker`]). +const BUNDLED_SKILL_VERSION: &str = "2"; + const SKILL_CREATOR_BODY: &str = include_str!("../../assets/skills/skill-creator/SKILL.md"); +const MEMORY_CONSOLIDATE_BODY: &str = + include_str!("../../assets/skills/memory-consolidate/SKILL.md"); + +/// The skills auto-installed on first launch, as `(dir_name, SKILL.md body)`. +const BUNDLED_SKILLS: &[(&str, &str)] = &[ + ("skill-creator", SKILL_CREATOR_BODY), + ("memory-consolidate", MEMORY_CONSOLIDATE_BODY), +]; + +const MARKER_NAME: &str = ".system-installed-version"; + +/// Parse the marker file into `(version, known_skill_names)`. +/// +/// Format is line-based: the first line is the bundle version, each subsequent +/// line is the name of a skill that has been installed at some point. Legacy +/// markers (a bare version string with no skill list) predate every skill but +/// `skill-creator`, so they default the known-set to `{skill-creator}` — that +/// keeps "user deleted it, don't recreate" working across the format change. +fn parse_marker(raw: &str) -> (Option, BTreeSet) { + let mut lines = raw.lines(); + let version = lines.next().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let mut known: BTreeSet = lines + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if version.is_some() && known.is_empty() { + // Legacy single-line marker. + known.insert("skill-creator".to_string()); + } + (version, known) +} + +fn render_marker(version: &str, known: &BTreeSet) -> String { + let mut out = String::from(version); + for name in known { + out.push('\n'); + out.push_str(name); + } + out +} /// Install bundled system skills into `skills_dir`. /// -/// Behaviour: -/// - Fresh install (no marker, no dir): installs `skill-creator/SKILL.md` and writes -/// the version marker. -/// - Version bump (marker present with older version, dir present): re-installs. -/// - User deleted the dir while marker still present at same version: leaves it gone. -/// - Idempotent: calling twice with no changes is a no-op. +/// Per skill, install when any of: +/// - **Fresh install** — no marker at all (first launch). +/// - **Newly bundled** — the skill isn't in the marker's known-set, so the user +/// has never seen it; lay it down even on an existing install. +/// - **Version bump** — the bundle version changed and the skill dir still +/// exists (upgrade in place). /// -/// Errors are I/O errors from the filesystem; the caller should log them but not -/// abort startup. +/// Skip when the version is current, or when a previously-known skill's dir is +/// gone (the user deleted it on purpose — respect that). +/// +/// Idempotent: a second call with nothing changed writes nothing. Errors are +/// filesystem I/O errors; the caller should log but not abort startup. pub fn install_system_skills(skills_dir: &Path) -> std::io::Result<()> { - let marker = skills_dir.join(".system-installed-version"); - let target_dir = skills_dir.join("skill-creator"); - let target_file = target_dir.join("SKILL.md"); - - let installed_version = fs::read_to_string(&marker) + let marker = skills_dir.join(MARKER_NAME); + let (recorded_version, mut known) = fs::read_to_string(&marker) .ok() - .map(|s| s.trim().to_string()); - let dir_exists = target_dir.exists(); - - // Re-install only when BOTH conditions hold: - // (a) bundled version is newer than what is recorded in the marker, AND - // (b) the skill directory still exists (user hasn't intentionally deleted it). - // Fresh install (no marker AND no dir) is also handled. - let should_install = match (installed_version.as_deref(), dir_exists) { - // Fresh install: neither marker nor directory. - (None, false) => true, - // Version bump: marker is outdated but directory still present. - (Some(v), true) if v != BUNDLED_SKILL_VERSION => true, - // Every other case: already installed at current version, or user deleted - // the dir (respect that choice). - _ => false, - }; - - if should_install { + .map(|raw| parse_marker(&raw)) + .unwrap_or((None, BTreeSet::new())); + + let fresh = recorded_version.is_none(); + let version_changed = recorded_version.as_deref() != Some(BUNDLED_SKILL_VERSION); + + let mut wrote_any = false; + for (name, body) in BUNDLED_SKILLS { + let target_dir = skills_dir.join(name); + let target_file = target_dir.join("SKILL.md"); + let dir_exists = target_dir.exists(); + let is_known = known.contains(*name); + + let should_install = if fresh { + true + } else if !is_known { + true + } else { + version_changed && dir_exists + }; + + if should_install { + fs::create_dir_all(&target_dir)?; + fs::write(&target_file, body)?; + wrote_any = true; + } + } + + // Every bundled skill is "known" after this run (installed or + // deletion-respected), so future runs keep honoring user deletions. + let all_names: BTreeSet = BUNDLED_SKILLS.iter().map(|(n, _)| n.to_string()).collect(); + let known_grew = !all_names.is_subset(&known); + known.extend(all_names); + + if fresh || version_changed || wrote_any || known_grew { fs::create_dir_all(skills_dir)?; - fs::create_dir_all(&target_dir)?; - fs::write(&target_file, SKILL_CREATOR_BODY)?; - fs::write(&marker, BUNDLED_SKILL_VERSION)?; + fs::write(&marker, render_marker(BUNDLED_SKILL_VERSION, &known))?; } Ok(()) } -/// Remove the `skill-creator` system skill and its version marker. +/// Remove all bundled system skills and the version marker. /// /// Intended for tests and `deepseek setup --clean`. Ignores missing files. #[allow(dead_code)] pub fn uninstall_system_skills(skills_dir: &Path) -> std::io::Result<()> { - let marker = skills_dir.join(".system-installed-version"); - let target_dir = skills_dir.join("skill-creator"); - - if target_dir.exists() { - fs::remove_dir_all(&target_dir)?; + for (name, _) in BUNDLED_SKILLS { + let target_dir = skills_dir.join(name); + if target_dir.exists() { + fs::remove_dir_all(&target_dir)?; + } } + let marker = skills_dir.join(MARKER_NAME); if marker.exists() { fs::remove_file(&marker)?; } @@ -82,6 +144,12 @@ mod tests { tmp.path().join(".system-installed-version") } + /// The version recorded in the marker (its first line). + fn marker_version(tmp: &TempDir) -> String { + let raw = fs::read_to_string(marker_file(tmp)).unwrap(); + raw.lines().next().unwrap_or_default().trim().to_string() + } + // ── fresh install ───────────────────────────────────────────────────────── #[test] @@ -91,9 +159,7 @@ mod tests { assert!(skill_file(&tmp).exists(), "SKILL.md should be created"); assert!(marker_file(&tmp).exists(), "marker should be created"); - - let ver = fs::read_to_string(marker_file(&tmp)).unwrap(); - assert_eq!(ver.trim(), BUNDLED_SKILL_VERSION); + assert_eq!(marker_version(&tmp), BUNDLED_SKILL_VERSION); } // ── idempotence ─────────────────────────────────────────────────────────── @@ -158,9 +224,8 @@ mod tests { "re-installed file must match the bundled body" ); - let ver = fs::read_to_string(marker_file(&tmp)).unwrap(); assert_eq!( - ver.trim(), + marker_version(&tmp), BUNDLED_SKILL_VERSION, "marker should be updated" ); @@ -184,4 +249,66 @@ mod tests { // Must not panic or error. uninstall_system_skills(tmp.path()).unwrap(); } + + // ── multi-skill bundle ──────────────────────────────────────────────────── + + #[test] + fn fresh_install_lays_down_every_bundled_skill() { + let tmp = TempDir::new().unwrap(); + install_system_skills(tmp.path()).unwrap(); + + for (name, _) in BUNDLED_SKILLS { + assert!( + tmp.path().join(name).join("SKILL.md").exists(), + "bundled skill `{name}` should be installed on fresh launch" + ); + } + // Marker records the version plus every bundled skill name. + let (_, known) = parse_marker(&fs::read_to_string(marker_file(&tmp)).unwrap()); + for (name, _) in BUNDLED_SKILLS { + assert!(known.contains(*name), "`{name}` should be recorded as known"); + } + } + + #[test] + fn newly_bundled_skill_installs_on_upgrade_from_legacy_marker() { + let tmp = TempDir::new().unwrap(); + + // Simulate an old install that only knew skill-creator: legacy + // single-line marker + just the skill-creator dir present. + let sc_dir = tmp.path().join("skill-creator"); + fs::create_dir_all(&sc_dir).unwrap(); + fs::write(sc_dir.join("SKILL.md"), "old skill-creator").unwrap(); + fs::write(marker_file(&tmp), "1").unwrap(); + + install_system_skills(tmp.path()).unwrap(); + + // The skill the user never saw is laid down... + assert!( + tmp.path().join("memory-consolidate").join("SKILL.md").exists(), + "a newly-bundled skill must install on upgrade" + ); + // ...and the version-bumped existing skill is refreshed. + assert_eq!( + fs::read_to_string(sc_dir.join("SKILL.md")).unwrap(), + SKILL_CREATOR_BODY, + "version bump should refresh the existing skill body" + ); + } + + #[test] + fn deleted_bundled_skill_is_not_recreated_at_current_version() { + let tmp = TempDir::new().unwrap(); + install_system_skills(tmp.path()).unwrap(); + + // User deletes one bundled skill; marker still records it as known. + fs::remove_dir_all(tmp.path().join("memory-consolidate")).unwrap(); + + install_system_skills(tmp.path()).unwrap(); + + assert!( + !tmp.path().join("memory-consolidate").join("SKILL.md").exists(), + "a deleted bundled skill must not be recreated at the current version" + ); + } } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index abfb25bd8..d484b65c1 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2636,6 +2636,35 @@ fn build_subagent_system_prompt( agent_type: &SubAgentType, assignment: &SubAgentAssignment, ) -> String { + let agents_dir = crate::agents::default_agents_dir(); + build_subagent_system_prompt_in(agent_type, assignment, agents_dir.as_deref()) +} + +/// Inner, dir-injectable form so tests stay hermetic. A file-defined agent at +/// `/.md` — keyed by the explicit role first, then the +/// agent-type name — overrides the built-in role intro. The shared output +/// contract is still appended so file-defined agents stay well-behaved. +fn build_subagent_system_prompt_in( + agent_type: &SubAgentType, + assignment: &SubAgentAssignment, + agents_dir: Option<&std::path::Path>, +) -> String { + if let Some(dir) = agents_dir { + let candidates = [ + assignment + .role + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()), + Some(agent_type.as_str()), + ]; + for name in candidates.into_iter().flatten() { + if let Some(def) = crate::agents::load_agent_definition(dir, name) { + return format!("{}\n\n{SUBAGENT_OUTPUT_FORMAT}", def.body.trim()); + } + } + } + let base = agent_type.system_prompt(); match assignment.role.as_deref() { Some(role) if !role.trim().is_empty() => { diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 97ae5f8ce..e3567f9e1 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1043,7 +1043,8 @@ fn parse_spawn_request_cwd_empty_string_yields_none() { #[test] fn build_subagent_system_prompt_appends_role_when_set() { let assignment = SubAgentAssignment::new("p".to_string(), Some("worker".to_string())); - let prompt = build_subagent_system_prompt(&SubAgentType::General, &assignment); + // `None` agents dir → no file override; exercise the built-in fallback. + let prompt = build_subagent_system_prompt_in(&SubAgentType::General, &assignment, None); assert!( prompt.ends_with("You are operating in the role of `worker`."), "expected role line at end, got: {}", @@ -1054,17 +1055,42 @@ fn build_subagent_system_prompt_appends_role_when_set() { #[test] fn build_subagent_system_prompt_skips_role_when_none() { let assignment = SubAgentAssignment::new("p".to_string(), None); - let prompt = build_subagent_system_prompt(&SubAgentType::General, &assignment); + let prompt = build_subagent_system_prompt_in(&SubAgentType::General, &assignment, None); assert!(!prompt.contains("You are operating in the role of")); } #[test] fn build_subagent_system_prompt_skips_role_when_blank() { let assignment = SubAgentAssignment::new("p".to_string(), Some(" ".to_string())); - let prompt = build_subagent_system_prompt(&SubAgentType::General, &assignment); + let prompt = build_subagent_system_prompt_in(&SubAgentType::General, &assignment, None); assert!(!prompt.contains("You are operating in the role of")); } +#[test] +fn file_defined_agent_overrides_builtin_role_prompt() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("code-review.md"), + "---\nname: code-review\ndescription: d\n---\nYou are a custom reviewer agent.", + ) + .unwrap(); + + // Keyed by the explicit role name. + let assignment = SubAgentAssignment::new("p".to_string(), Some("code-review".to_string())); + let prompt = + build_subagent_system_prompt_in(&SubAgentType::Review, &assignment, Some(dir.path())); + assert!(prompt.contains("You are a custom reviewer agent.")); + assert!(!prompt.contains("You are operating in the role of")); + // Shared output contract is still appended. + assert!(prompt.contains(SUBAGENT_OUTPUT_FORMAT)); + + // Unknown role with no file → built-in fallback (no override). + let other = SubAgentAssignment::new("p".to_string(), Some("nope".to_string())); + let fallback = + build_subagent_system_prompt_in(&SubAgentType::Review, &other, Some(dir.path())); + assert!(!fallback.contains("You are a custom reviewer agent.")); +} + #[test] fn subagent_done_sentinel_format_is_well_formed() { let res = make_snapshot(SubAgentStatus::Completed); diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 6f06f5cd1..d7d0e5cc2 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -36,12 +36,18 @@ const TOOL_STATUS_SYMBOL_MS: u64 = 720; const USER_GLYPH: &str = "\u{258E}"; // ▎ /// Visual marker for the assistant role. Solid bullet that pulses at 2s /// cycle while the response is streaming, holds full brightness when idle. -const ASSISTANT_GLYPH: &str = "\u{25CF}"; // ● +const ASSISTANT_GLYPH: &str = "\u{23FA}"; // ⏺ Claude Code action bullet /// Transcript body left rail. Solid 1/8 block (`▏`) followed by a space — /// used as a visual left-margin anchor for continuation lines, tool-card /// detail rows, and affordance lines. Dimmed so it guides the eye without /// competing with content. const TRANSCRIPT_RAIL: &str = "\u{258F} "; // ▏ + space +/// Claude Code-style tree connector for tool result/detail lines. The first +/// line of a result block carries `⎿ `; continuation lines indent two columns +/// to align under it. Width matches [`TRANSCRIPT_RAIL`] (2 cols) so wrap math +/// is unchanged. +const TOOL_RESULT_CONNECTOR: &str = "\u{23BF} "; // ⎿ + space +const TOOL_RESULT_INDENT: &str = " "; // continuation alignment under ⎿ /// Reasoning header opener. Replaces the spinner glyph on thinking cells — /// reasoning is a slow exhale, not a tool spin. const REASONING_OPENER: &str = "\u{2026}"; // … @@ -53,8 +59,8 @@ const REASONING_RAIL: &str = "\u{254E} "; // ╎ + space const REASONING_CURSOR: &str = "\u{258E}"; // ▎ const TOOL_CARD_SUMMARY_LINES: usize = 4; const THINKING_SUMMARY_LINE_LIMIT: usize = 4; -const TOOL_DONE_SYMBOL: &str = "•"; -const TOOL_FAILED_SYMBOL: &str = "•"; +const TOOL_DONE_SYMBOL: &str = "\u{23FA}"; // ⏺ Claude Code completed-action bullet +const TOOL_FAILED_SYMBOL: &str = "\u{23FA}"; // ⏺ (red via tool_state_color) /// Render mode controlling whether tool/thinking cells render their compact /// "live" form (with caps and collapsed reasoning) or their full transcript @@ -2683,7 +2689,7 @@ fn status_symbol(started_at: Option, status: ToolStatus, low_motion: bo fn details_affordance_line(text: &str, style: Style) -> Line<'static> { Line::from(vec![ Span::styled( - TRANSCRIPT_RAIL.to_string(), + TOOL_RESULT_INDENT.to_string(), Style::default().fg(palette::TEXT_DIM), ), Span::styled(text.to_string(), style), @@ -2898,15 +2904,20 @@ fn render_card_detail_line( width: u16, ) -> Vec> { let label_text = label.map(|text| format!("{text}:")); - let prefix_width = UnicodeWidthStr::width(TRANSCRIPT_RAIL) + let prefix_width = UnicodeWidthStr::width(TOOL_RESULT_CONNECTOR) + label_text.as_deref().map_or(0, UnicodeWidthStr::width) + usize::from(label.is_some()); let content_width = usize::from(width).saturating_sub(prefix_width).max(1); let mut lines = Vec::new(); for (idx, part) in wrap_text(value, content_width).into_iter().enumerate() { + let rail = if idx == 0 { + TOOL_RESULT_CONNECTOR + } else { + TOOL_RESULT_INDENT + }; let mut spans = vec![Span::styled( - TRANSCRIPT_RAIL.to_string(), + rail.to_string(), Style::default().fg(palette::TEXT_DIM), )]; if idx == 0 { @@ -2935,7 +2946,7 @@ fn render_card_detail_line_single( ) -> Line<'static> { let label_text = label.map(|text| format!("{text}:")); let mut spans = vec![Span::styled( - TRANSCRIPT_RAIL.to_string(), + TOOL_RESULT_CONNECTOR.to_string(), Style::default().fg(palette::TEXT_DIM), )]; if let Some(label_text) = label_text { @@ -4223,9 +4234,9 @@ mod tests { .collect::() }) .collect::>(); - assert_eq!(visible[1].trim_end(), "▏ done: scan repo"); - assert_eq!(visible[2].trim_end(), "▏ live: extract theme"); - assert_eq!(visible[3].trim_end(), "▏ next: land tests"); + assert_eq!(visible[1].trim_end(), "⎿ done: scan repo"); + assert_eq!(visible[2].trim_end(), "⎿ live: extract theme"); + assert_eq!(visible[3].trim_end(), "⎿ next: land tests"); } #[test] diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2f01ed9e9..12e3c30e4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1287,6 +1287,13 @@ async fn run_event_loop( threshold, turn_elapsed, ); + // Notification hook (Claude Code `Notification` + // analog): mirror the user-facing notification to + // any configured external notifier. + if app.hooks.has_hooks_for_event(HookEvent::Notification) { + let ctx = app.base_hook_context().with_message(&msg); + let _ = app.execute_hooks(HookEvent::Notification, &ctx); + } } // Auto-save completed turn and clear crash checkpoint. @@ -1299,6 +1306,20 @@ async fn run_event_loop( } persistence_actor::persist(PersistRequest::ClearCheckpoint); + // Stop hook: the assistant finished a turn (Claude Code + // `Stop` analog). Fires for completed / interrupted / + // failed so user-configured notifiers, auto-format, or + // auto-test can run on completion. + if app.hooks.has_hooks_for_event(HookEvent::Stop) { + let status_label = match status { + crate::core::events::TurnOutcomeStatus::Completed => "completed", + crate::core::events::TurnOutcomeStatus::Interrupted => "interrupted", + crate::core::events::TurnOutcomeStatus::Failed => "failed", + }; + let ctx = app.base_hook_context().with_turn_status(status_label); + let _ = app.execute_hooks(HookEvent::Stop, &ctx); + } + if app.mode == AppMode::Plan && app.plan_tool_used_in_turn && !app.plan_prompt_pending @@ -1375,6 +1396,13 @@ async fn run_event_loop( } } EngineEvent::CompactionStarted { message, .. } => { + // PreCompact hook (Claude Code analog): fires as + // compaction begins, before the summary replaces the + // transcript. Covers manual /compact and auto-compaction. + if app.hooks.has_hooks_for_event(HookEvent::PreCompact) { + let ctx = app.base_hook_context(); + let _ = app.execute_hooks(HookEvent::PreCompact, &ctx); + } app.is_compacting = true; app.status_message = Some(message); } @@ -1493,6 +1521,14 @@ async fn run_event_loop( "Sub-agent {id} completed: {}", summarize_tool_output(&result) )); + // SubagentStop hook (Claude Code analog): fires when a + // sub-agent finishes, carrying its id and result summary. + if app.hooks.has_hooks_for_event(HookEvent::SubagentStop) { + let ctx = app + .base_hook_context() + .with_message(&format!("subagent {id}: {}", summarize_tool_output(&result))); + let _ = app.execute_hooks(HookEvent::SubagentStop, &ctx); + } let should_recapture_terminal = !has_other_running_subagents && app.use_alt_screen; if !has_other_running_subagents diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 1250e2c64..41a50124a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -395,6 +395,12 @@ fn render_jump_to_latest_button(area: Rect, buf: &mut Buffer, background: Color) ); } +/// Claude Code-style input prompt drawn at the start of the composer's first +/// row; continuation rows indent by the same width to align under it. +const COMPOSER_PROMPT: &str = "> "; +const COMPOSER_PROMPT_INDENT: &str = " "; +const COMPOSER_PROMPT_WIDTH: usize = 2; + pub struct ComposerWidget<'a> { app: &'a App, max_height: u16, @@ -504,8 +510,12 @@ impl Renderable for ComposerWidget<'_> { let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines_for_budget); let content_width = usize::from(inner_area.width.max(1)); + // Reserve the prompt gutter ("> ") so pre-wrapped input lines plus the + // prompt prefix never exceed the inner width (which would make the + // wrapping Paragraph re-wrap them). + let input_width = content_width.saturating_sub(COMPOSER_PROMPT_WIDTH).max(1); let (visible_lines, _cursor_row, _cursor_col) = - layout_input(input_text, input_cursor, content_width, input_rows_budget); + layout_input(input_text, input_cursor, input_width, input_rows_budget); let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { let border_color = if input_text.trim().is_empty() { @@ -606,6 +616,7 @@ impl Renderable for ComposerWidget<'_> { Style::default().fg(palette::TEXT_MUTED), ))) .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(border_color)) .style(background); // Vim mode indicator — shown in the top-right corner of the @@ -630,6 +641,13 @@ impl Renderable for ComposerWidget<'_> { Block::default().style(background).render(area, buf); } + let prompt_span = Span::styled( + COMPOSER_PROMPT, + Style::default() + .fg(palette::ACCENT_PRIMARY) + .add_modifier(Modifier::BOLD), + ); + let prompt_indent_span = || Span::raw(COMPOSER_PROMPT_INDENT); let mut input_lines = Vec::new(); if input_text.is_empty() { let placeholder = if self.app.is_history_search_active() { @@ -639,16 +657,24 @@ impl Renderable for ComposerWidget<'_> { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) }; - input_lines.push(Line::from(Span::styled( - placeholder, - Style::default().fg(palette::TEXT_MUTED).italic(), - ))); + input_lines.push(Line::from(vec![ + prompt_span.clone(), + Span::styled( + placeholder, + Style::default().fg(palette::TEXT_MUTED).italic(), + ), + ])); } else { - for line in &visible_lines { - input_lines.push(Line::from(Span::styled( - line.clone(), - Style::default().fg(palette::TEXT_PRIMARY), - ))); + for (idx, line) in visible_lines.iter().enumerate() { + let lead = if idx == 0 { + prompt_span.clone() + } else { + prompt_indent_span() + }; + input_lines.push(Line::from(vec![ + lead, + Span::styled(line.clone(), Style::default().fg(palette::TEXT_PRIMARY)), + ])); } } @@ -664,7 +690,7 @@ impl Renderable for ComposerWidget<'_> { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) }; - placeholder_visual_lines_for(placeholder, content_width) + placeholder_visual_lines_for(placeholder, input_width) } else { input_lines.len() }; @@ -930,13 +956,17 @@ impl Renderable for ComposerWidget<'_> { let input_text = self.app.composer_display_input(); let input_cursor = self.app.composer_display_cursor(); let content_width = usize::from(inner_area.width.max(1)); + // Mirror the render path: the prompt gutter ("> ") narrows the text + // column, so wrap math must use the same reduced width and the cursor + // must shift right by the prompt width. + let input_width = content_width.saturating_sub(COMPOSER_PROMPT_WIDTH).max(1); // Match the render path's locked-budget calculation so the cursor // lands on the same row the input is drawn on. let input_rows_budget = composer_input_rows_budget(inner_area.height, self.active_menu_reserved_rows()); let (visible_lines, cursor_row, cursor_col) = - layout_input(input_text, input_cursor, content_width, input_rows_budget); + layout_input(input_text, input_cursor, input_width, input_rows_budget); let visual_rows = if input_text.is_empty() { let placeholder = if self.app.is_history_search_active() { self.app @@ -945,7 +975,7 @@ impl Renderable for ComposerWidget<'_> { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) }; - placeholder_visual_lines_for(placeholder, content_width) + placeholder_visual_lines_for(placeholder, input_width) } else { visible_lines.len() }; @@ -954,6 +984,7 @@ impl Renderable for ComposerWidget<'_> { let cursor_x = area .x .saturating_add(inner_area.x.saturating_sub(area.x)) + .saturating_add(COMPOSER_PROMPT_WIDTH as u16) .saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX)); let cursor_y = area .y @@ -1859,31 +1890,120 @@ fn should_render_empty_state(app: &App) -> bool { app.history.is_empty() && !app.is_loading && !app.is_compacting } +/// Truncate `s` from the left, keeping the most informative tail, so it fits +/// in `max` display columns. Prepends `…` when truncated. +fn fit_tail(s: &str, max: usize) -> String { + if s.width() <= max { + return s.to_string(); + } + let budget = max.saturating_sub(1); // room for the leading ellipsis + let mut kept: Vec = Vec::new(); + let mut w = 0usize; + for ch in s.chars().rev() { + let cw = ch.width().unwrap_or(0); + if w + cw > budget { + break; + } + kept.push(ch); + w += cw; + } + kept.reverse(); + let mut out = String::from("…"); + out.extend(kept); + out +} + +/// One content row inside the welcome box: `│ ` + spans + right-pad + ` │`, +/// preceded by the centering inset. `spans` must total ≤ `inner` columns. +fn banner_row(inset: &str, inner: usize, spans: Vec>, border: Style) -> Line<'static> { + let used: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + let pad = inner.saturating_sub(used); + let mut out = vec![ + Span::raw(inset.to_string()), + Span::styled("\u{2502} ", border), // "│ " + ]; + out.extend(spans); + out.push(Span::raw(" ".repeat(pad))); + out.push(Span::styled(" \u{2502}", border)); // " │" + Line::from(out) +} + fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { if area.width == 0 || area.height == 0 { return Vec::new(); } let workspace = crate::utils::display_path(&app.workspace); - let body_width = usize::from(area.width.saturating_sub(8).clamp(24, 72)); - let left_padding = usize::from(area.width.saturating_sub(body_width as u16) / 2); + // Box width, centered, bounded so it reads as a card on wide terminals. + let box_width = usize::from(area.width.saturating_sub(4)).clamp(36, 72); + let inner = box_width.saturating_sub(4); // "│ " + content + " │" + let left_padding = usize::from(area.width).saturating_sub(box_width) / 2; let inset = " ".repeat(left_padding); - let body = vec![ - Line::from(Span::styled( - format!("{inset}>_ DeepSeek TUI (v{})", env!("CARGO_PKG_VERSION")), - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - )), - Line::from(""), - Line::from(Span::styled( - format!("{inset}model: {} /model to switch", app.model), - Style::default().fg(palette::TEXT_MUTED), - )), - Line::from(Span::styled( - format!("{inset}directory: {workspace}"), - Style::default().fg(palette::TEXT_MUTED), - )), - ]; + let border = Style::default().fg(palette::ACCENT_PRIMARY); + let dim = Style::default().fg(palette::TEXT_MUTED); + + let top = Line::from(vec![ + Span::raw(inset.clone()), + Span::styled( + format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(box_width.saturating_sub(2))), + border, + ), + ]); + let bottom = Line::from(vec![ + Span::raw(inset.clone()), + Span::styled( + format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(box_width.saturating_sub(2))), + border, + ), + ]); + + let mut body = vec![top]; + body.push(banner_row( + &inset, + inner, + vec![ + Span::styled("\u{273B} ", Style::default().fg(palette::ACCENT_PRIMARY).bold()), // ✻ + Span::styled( + fit_tail( + &format!("Welcome to DeepSeek TUI v{}", env!("CARGO_PKG_VERSION")), + inner.saturating_sub(2), + ), + Style::default().fg(palette::TEXT_PRIMARY).bold(), + ), + ], + border, + )); + body.push(banner_row(&inset, inner, vec![Span::raw("")], border)); + body.push(banner_row( + &inset, + inner, + vec![Span::styled( + fit_tail(&format!("model: {}", app.model), inner), + dim, + )], + border, + )); + body.push(banner_row( + &inset, + inner, + vec![Span::styled(fit_tail(&format!("cwd: {workspace}"), inner), dim)], + border, + )); + body.push(banner_row(&inset, inner, vec![Span::raw("")], border)); + body.push(banner_row( + &inset, + inner, + vec![Span::styled( + fit_tail("/help for commands · /model to switch · type / to begin", inner), + dim, + )], + border, + )); + body.push(bottom); let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3); let mut lines = Vec::new(); @@ -2547,12 +2667,12 @@ mod tests { }; // inner_area: {x:1, y:1, w:38, h:3} (borders shrink by 1 each side) + // input_width = 38 - 2 (prompt gutter "> ") = 36 // input_rows_budget = 3 - // placeholder_visual_lines(38) = 1 (placeholder is 22 chars, fits in 38) - // top_padding = 3 - clamp(1, 1, 3) = 2 - // cursor_x = 0 + (1-0) + 0 = 1 + // placeholder fits in 36 → 1 line; top_padding = 3 - clamp(1,1,3) = 2 + // cursor_x = 0 + (1-0) + 2 (prompt) + 0 = 3 // cursor_y = 0 + (1-0) + (2+0) = 3 - assert_eq!(widget.cursor_pos(area), Some((1, 3))); + assert_eq!(widget.cursor_pos(area), Some((3, 3))); } #[test] @@ -2572,13 +2692,13 @@ mod tests { }; // inner_area: {x:1, y:1, w:12, h:3} + // input_width = 12 - 2 (prompt gutter "> ") = 10 // input_rows_budget = 3 - // placeholder_visual_lines(12) = 2 ("Write a task" / " or use /.") - // top_padding = 3 - clamp(2, 1, 3) = 1 - // cursor_x = 0 + (1-0) + 0 = 1 - // cursor_y = 0 + (1-0) + (1+0) = 2 - assert_eq!(placeholder_visual_lines(12), 2); - assert_eq!(widget.cursor_pos(area), Some((1, 2))); + // placeholder wraps to 3 lines at width 10 → top_padding = 3 - clamp(3,1,3) = 0 + // cursor_x = 0 + (1-0) + 2 (prompt) + 0 = 3 + // cursor_y = 0 + (1-0) + (0+0) = 1 + assert_eq!(placeholder_visual_lines(10), 3); + assert_eq!(widget.cursor_pos(area), Some((3, 1))); } #[test] @@ -2646,7 +2766,9 @@ mod tests { height: 3, }; - assert_eq!(widget.cursor_pos(area), Some((0, 2))); + // No border: inner == area. Prompt gutter still shifts the cursor +2. + // cursor_x = 0 + 0 + 2 (prompt) + 0 = 2; cursor_y = 2. + assert_eq!(widget.cursor_pos(area), Some((2, 2))); } #[test] @@ -2716,9 +2838,10 @@ mod tests { .collect::>() .join("\n"); - assert!(rendered.contains(&format!(">_ DeepSeek TUI (v{})", env!("CARGO_PKG_VERSION")))); - assert!(rendered.contains("model: deepseek-v4-pro /model to switch")); - assert!(rendered.contains("directory: /tmp/deepseek-test-workspace")); + assert!(rendered.contains("\u{273B}")); // ✻ welcome icon + assert!(rendered.contains(&format!("Welcome to DeepSeek TUI v{}", env!("CARGO_PKG_VERSION")))); + assert!(rendered.contains("model: deepseek-v4-pro")); + assert!(rendered.contains("cwd: /tmp/deepseek-test-workspace")); } /// Probe: confirm `cell.lines_with_motion` returns no Line whose total