diff --git a/README.md b/README.md index bc03c77..5726d4d 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,15 @@ Recent highlights: ### Install ```bash -npm install -g @opencoven/coven-code +npm install -g @opencoven/coven ``` -Then open a new terminal and run `coven-code`. `coven-cave` is also installed as an alias for the same CLI. +Then open a new terminal and run `coven` or `coven tui`. ### Upgrade ```bash -npm install -g @opencoven/coven-code@latest +npm install -g @opencoven/coven@latest ``` --- diff --git a/docs/commands.md b/docs/commands.md index cedd984..953b8d4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -282,7 +282,7 @@ Common keys: | `outputStyle` | Output rendering style | | `autoApprove` | Auto-approve tool calls | -Subcommands: `/config keybindings`, `/config theme [name]`, `/config output-style [style]`, `/config import`, `/config advisor [model|off]`, `/config color`, `/config vim`, `/config voice`, `/config statusline`, `/config terminal-setup` — each documented below or under [Display & Terminal](#display--terminal). +Subcommands: `/config keybindings`, `/config theme [name]`, `/config output-style [style]`, `/config import`, `/config advisor [model|off]`, `/config color`, `/config vim`, `/config voice`, `/config statusline`, `/config terminal-setup` — each documented below or under [Display & Terminal](#display--terminal). Use `/splash` for the empty-session welcome panel. `/config import` imports user-level Claude Code configuration (`CLAUDE.md`, `settings.json`) from `~/.claude` via an interactive dialog with preview and confirmation. It replaces the former `/import-config` command. @@ -300,6 +300,19 @@ See [keybindings.md](./keybindings.md) for the full keybindings reference. --- +### /splash + +Show, hide, or toggle the empty-session welcome/splash panel. The setting is written to `~/.coven-code/settings.json` under `config.showSplash`. + +``` +/splash — toggle the splash screen +/splash show — show it +/splash hide — hide it +/splash status — show current state +``` + +--- + ### /permissions View and manage tool permission rules. Permissions control which tools can run without prompting, which are blocked, and which always require confirmation. diff --git a/docs/index.md b/docs/index.md index 3f109fc..abf957e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ Coven Code is a high-performance Rust reimplementation of Claude Code — a term You give Coven Code a task in natural language. It plans, reads and writes files, runs shell commands, searches the web, and iterates — all inside your terminal, with every step visible in real time. ``` -$ coven-code "add input validation to the signup form" +$ coven run codex "add input validation to the signup form" ``` Coven Code reads your codebase, implements the change across multiple files, runs your tests, and reports back — without you leaving the terminal. @@ -73,17 +73,12 @@ Cast `/incant caveman` or `/incant rocky` to compress model responses by 40–85 ```bash # Linux / macOS -curl -fsSL https://github.com/OpenCoven/coven-code/releases/latest/download/install.sh | bash -``` - -```powershell -# Windows (PowerShell) -irm https://github.com/OpenCoven/coven-code/releases/latest/download/install.ps1 | iex +npm install -g @opencoven/coven ``` -The installer auto-detects your platform/arch, drops `coven-code` into -`~/.coven-code/bin/`, and adds it to your `PATH`. See -[Installation](installation) for flags, manual download, and uninstall steps. +The package installs the `coven` CLI. Run `coven` with no arguments, or +`coven tui` explicitly, for the interactive UI. See [Installation](installation) +for npm, bun, standalone binary, and source install options. **2. Set your API key** @@ -94,13 +89,13 @@ export ANTHROPIC_API_KEY=sk-ant-... **3. Run interactively** ```bash -coven-code +coven ``` -Or send a single prompt and exit: +Or launch a direct harness session: ```bash -coven-code --print "explain the auth module" +coven run codex "explain the auth module" ``` --- @@ -142,17 +137,17 @@ See [Providers](providers) for setup instructions for every supported provider. | Mode | Command | Use case | |------|---------|----------| -| Interactive TUI | `coven-code` | Day-to-day coding | -| Single prompt | `coven-code "task"` | Quick one-shot tasks | -| Headless print | `coven-code --print "task"` | Scripts, CI | -| JSON output | `coven-code --output-format json "task"` | Machine consumption | -| Stream JSON | `coven-code --output-format stream-json "task"` | Real-time piping | +| Interactive TUI | `coven` or `coven tui` | Day-to-day coding | +| Direct harness run | `coven run codex "task"` | Quick one-shot tasks | +| Claude Code run | `coven run claude "task"` | Use Claude Code through Coven | +| Session browser | `coven sessions` | Rejoin, view, archive, or delete sessions | +| Stream JSON | `coven run codex "task" --stream-json` | Real-time piping | --- ## The welcome screen -When you launch `coven-code` interactively, the home screen opens with a single rounded panel titled `Coven Code v`. It's the at-a-glance status surface — every value comes from another subsystem, so use it as a jumping-off point rather than a source of truth. +When you launch the interactive UI with `coven` or `coven tui`, the home screen opens with a single rounded panel titled `Coven Code v`. It's the at-a-glance status surface — every value comes from another subsystem, so use it as a jumping-off point rather than a source of truth. **Left column** — your familiar's portrait (animated glyph for built-ins, static card for daemon-registered familiars) under a `Welcome back !` greeting. The art is driven by the `"familiar"` field in your settings; see [Coven Familiars](familiars). diff --git a/docs/installation.md b/docs/installation.md index ae433d9..95d0c0d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,9 +1,9 @@ # Coven Code Installation Guide -Coven Code is a Rust reimplementation of the Claude Code CLI. The fastest way -to install it is via the one-liner installers below. They drop the binary -into `~/.coven-code/bin` (or `%USERPROFILE%\.coven-code\bin` on Windows) and add -that directory to your `PATH` automatically. +Coven Code is a Rust reimplementation of the Claude Code CLI. The recommended +npm install path is the Coven package, which installs the `coven` CLI. Run +`coven` with no arguments, or `coven tui` explicitly, to open the interactive +Coven Code UI. --- @@ -24,6 +24,39 @@ possible; on Linux it links against the system glibc. ## Quick install (recommended) +If you have Node.js or Bun installed, install the Coven CLI globally: + +```bash +# npm +npm install -g @opencoven/coven + +# bun +bun install -g @opencoven/coven +``` + +After installation, run: + +```bash +coven +# or explicitly: +coven tui +``` + +The installed command is `coven`. Use `coven doctor` to inspect local setup, +`coven daemon start` to start the local daemon, and +`coven run ""` for direct harness sessions. + +You can also run Coven without a permanent install: + +```bash +npx @opencoven/coven # via npm +bunx @opencoven/coven # via bun +``` + +--- + +## Standalone Coven Code binary + ### Linux / macOS ```bash @@ -65,11 +98,11 @@ Example: `curl -fsSL https://.../install.sh | bash -s -- --version 0.1.0` --- -## Via npm / bun +## Coven Code npm package -If you have Node.js or Bun installed, you can install Coven Code as a global -package. The postinstall script automatically downloads the correct pre-built -native binary for your platform from GitHub Releases — no compilation needed. +The lower-level Coven Code npm package installs the `coven-code` binary +directly. Prefer `@opencoven/coven` for the user-facing `coven` CLI unless you +specifically need the underlying Coven Code binary. ```bash # npm @@ -104,14 +137,12 @@ bunx @opencoven/coven-code # via bun Once installed, upgrade in place at any time: ```bash -coven-code upgrade # to the latest release -coven-code upgrade --version 0.1.0 # pin to a specific version -coven-code upgrade --force # reinstall the same version +npm install -g @opencoven/coven@latest +# or +bun install -g @opencoven/coven@latest ``` -The upgrade command downloads the matching archive from GitHub, extracts the -new binary, and replaces the running executable atomically. Settings in -`~/.coven-code/` are preserved. +Settings in `~/.coven/` and `~/.coven-code/` are preserved. --- @@ -194,14 +225,17 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source "$HOME/.cargo/env" ``` -### Option A: Install via Cargo +### Option A: Install from a clone ```bash -cargo install coven-code --force +git clone https://github.com/OpenCoven/coven-code.git +cd coven-code/src-rust +cargo install --path crates/cli --locked ``` -This downloads, compiles, and installs the binary to `~/.cargo/bin/coven-code`. -That directory is added to `PATH` automatically by `rustup`. +This compiles the `claurst` package and installs the `coven-code` binary to +`~/.cargo/bin/coven-code`. That directory is added to `PATH` automatically by +`rustup`. ### Option B: Clone and Build @@ -210,10 +244,10 @@ git clone https://github.com/OpenCoven/coven-code.git cd coven-code/src-rust # Debug build (fast to compile, larger binary, extra runtime checks) -cargo build --package coven-code +cargo build --package claurst # Release build (optimised, smaller, suitable for everyday use) -cargo build --release --package coven-code +cargo build --release --package claurst ``` The release binary is placed at: @@ -252,8 +286,8 @@ sudo pacman -S alsa-lib openssl To enable a feature: ```bash -cargo build --release --package coven-code --features voice -cargo build --release --package coven-code --features dev_full +cargo build --release --package claurst --features voice +cargo build --release --package claurst --features dev_full ``` ### Cross-compiling for Linux aarch64 @@ -264,7 +298,7 @@ aarch64 Linux builds. To reproduce it locally: ```bash cargo install cross --git https://github.com/cross-rs/cross cd src-rust -cross build --release --locked --package coven-code --target aarch64-unknown-linux-gnu +cross build --release --locked --package claurst --target aarch64-unknown-linux-gnu ``` `cross` manages the Docker sysroot, OpenSSL, and ALSA headers automatically. @@ -273,16 +307,16 @@ cross build --release --locked --package coven-code --target aarch64-unknown-lin ## Shell Completions -Coven Code does not currently ship a dedicated `completions` subcommand. All -flags can be discovered via `coven-code --help`. If you want basic tab completion +Coven does not currently ship a dedicated `completions` subcommand. All +flags can be discovered via `coven --help`. If you want basic tab completion in bash or zsh you can use the generic completion helper built into your shell: ```bash # bash — add to ~/.bashrc -complete -C coven-code coven-code +complete -C coven coven # zsh — add to ~/.zshrc (requires compinit) -compdef _gnu_generic coven-code +compdef _gnu_generic coven ``` Richer completion scripts may be added in a future release. @@ -292,16 +326,25 @@ Richer completion scripts may be added in a future release. ## Upgrading a source install ```bash -cargo install coven-code --force +cd coven-code/src-rust +cargo install --path crates/cli --locked --force ``` -For binary installs (the recommended path), use `coven-code upgrade` — see -the [Upgrading](#upgrading) section above. +For npm or bun installs, reinstall the `@opencoven/coven` package — see the +[Upgrading](#upgrading) section above. --- ## Uninstalling +If you used the recommended npm or bun package, remove it globally: + +```bash +npm uninstall -g @opencoven/coven +# or +bun remove -g @opencoven/coven +``` + If you used the install script, remove the install directory: ```bash @@ -320,7 +363,7 @@ rm ~/.local/bin/coven-code # if installed user-local To also remove all settings and session data: ```bash -rm -rf ~/.coven-code +rm -rf ~/.coven ~/.coven-code ``` You may also want to remove the `# coven-code` PATH line that the installer diff --git a/docs/src/content/getting-started.js b/docs/src/content/getting-started.js index ae3b775..7d361a8 100644 --- a/docs/src/content/getting-started.js +++ b/docs/src/content/getting-started.js @@ -7,16 +7,10 @@ export function render() {

1. Install

-

Linux / macOS

-
curl -fsSL https://github.com/OpenCoven/coven-code/releases/latest/download/install.sh | bash
- -

Windows (PowerShell)

-
irm https://github.com/OpenCoven/coven-code/releases/latest/download/install.ps1 | iex
- -

The installer auto-detects your platform/arch, drops coven-code into ~/.coven-code/bin/, and adds it to your PATH.

-

npm

-
npm i -g coven-code
+
npm install -g @opencoven/coven
+ +

This installs the coven CLI. Run coven with no arguments, or coven tui explicitly, for the interactive UI.

From Source

git clone https://github.com/OpenCoven/coven-code
@@ -27,17 +21,17 @@ cargo install --path crates/cli
export ANTHROPIC_API_KEY=sk-ant-...
-

Or run coven-code /login to authenticate via OAuth (Claude.ai or ChatGPT). Multiple named accounts can coexist; switch with /switch <id>.

+

Or launch coven and run /login to authenticate via OAuth (Claude.ai or ChatGPT). Multiple named accounts can coexist; switch with /switch <id>.

3. Run Interactively

-
coven-code
+
coven

This drops you into the TUI. The first screen is the welcome screen, which surfaces the active model, provider, daemon status, and familiar.

-

Or send a single prompt and exit:

+

Or launch a direct harness session:

-
coven-code --print "explain the auth module"
+
coven run codex "explain the auth module"

Interactive vs Headless

@@ -51,35 +45,35 @@ cargo install --path crates/cli
interactive
day-to-day
Full ratatui TUI with streaming, slash commands, permission dialogs, session history. The default when you launch with no args.
-
coven-code
+
coven
-
single prompt
+
direct harness
one-shot -
One pass over a single task, then exit. TUI still renders the run so you can watch tools fire in real time.
-
coven-code "task"
+
Run one named harness against a task from the Coven CLI.
+
coven run codex "task"
-
headless print
- scripts · CI -
Plain text output to stdout — no TUI, no colour codes, no permission prompts. Use in shell pipelines and CI runners.
-
coven-code --print "task"
+
claude harness
+ alternate +
Use Claude Code through the same Coven harness runner.
+
coven run claude "task"
-
json output
+
sessions json
machine -
Single JSON document with the full run transcript, tool calls, and final result. Parse with jq or feed into downstream tooling.
-
coven-code --output-format json "task"
+
List known Coven sessions as JSON for scripts, dashboards, or local workflow tooling.
+
coven sessions --json
stream-json
real-time
Newline-delimited JSON events as they happen — useful for streaming progress into another process or live UI.
-
coven-code --output-format stream-json "task"
+
coven run codex "task" --stream-json
@@ -89,7 +83,8 @@ cargo install --path crates/cli

Coven Code connects natively to the Coven daemon when it's running on your machine. With the daemon active, familiars appear as agents, daemon-registered skills become awareness context, and the welcome panel animates with your familiar's glyph.

-
npm install -g @opencoven/coven
+
npm install -g @opencoven/coven
+coven daemon start

Coven Code is fully standalone without the daemon — install it separately to unlock the Coven ecosystem features.

`; diff --git a/docs/src/content/installation.js b/docs/src/content/installation.js index c3ddb40..b233fc3 100644 --- a/docs/src/content/installation.js +++ b/docs/src/content/installation.js @@ -3,7 +3,7 @@ export const meta = { title: 'Installation' }; export function render() { return `

Installation

-

A statically-linked Rust binary with no runtime dependencies. Install via the official installer script, npm, or build from source.

+

Install the Coven CLI with npm or bun, then run coven or coven tui to open the interactive Coven Code UI.

System Requirements

@@ -20,6 +20,17 @@ export function render() {

Quick Install

+
npm install -g @opencoven/coven
+# or
+bun install -g @opencoven/coven
+ +

The installed command is coven. Run coven with no arguments, or coven tui explicitly, for the interactive UI. Use coven doctor to inspect local setup, coven daemon start to start the local daemon, and coven run <harness> "<task>" for direct harness sessions.

+ +
npx @opencoven/coven
+bunx @opencoven/coven
+ +

Standalone Coven Code Binary

+

Linux / macOS

curl -fsSL https://github.com/OpenCoven/coven-code/releases/latest/download/install.sh | bash
@@ -42,7 +53,9 @@ export function render() { -

Via npm / bun

+

Coven Code npm Package

+ +

Prefer @opencoven/coven for the user-facing coven CLI. The lower-level Coven Code package installs the coven-code binary directly.

npm install -g @opencoven/coven-code
 # or
@@ -55,11 +68,11 @@ bunx @opencoven/coven-code

Upgrading

-
coven-code upgrade                  # to the latest release
-coven-code upgrade --version 0.1.0  # pin to a specific version
-coven-code upgrade --force          # reinstall the same version
+
npm install -g @opencoven/coven@latest
+# or
+bun install -g @opencoven/coven@latest
-

Settings under ~/.coven-code/ are preserved.

+

Settings under ~/.coven/ and ~/.coven-code/ are preserved.

Manual Install

@@ -78,9 +91,11 @@ coven-code upgrade --force # reinstall the same version

From Source

-

Via Cargo

+

From a Clone

-
cargo install --git https://github.com/OpenCoven/coven-code coven-code-cli
+
git clone https://github.com/OpenCoven/coven-code
+cd coven-code/src-rust
+cargo install --path crates/cli --locked

Clone and Build

@@ -88,10 +103,10 @@ coven-code upgrade --force # reinstall the same version cd coven-code/src-rust # Debug build (fast to compile, larger binary) -cargo build +cargo build --package claurst # Release build (optimised, smaller, for everyday use) -cargo build --release +cargo build --release --package claurst

Linux System Dependencies

@@ -106,18 +121,24 @@ sudo pacman -S base-devel pkgconf openssl

Shell Completions

+

Coven does not currently ship a dedicated completions subcommand. All flags can be discovered via coven --help. If you want basic tab completion in bash or zsh, use the generic completion helper built into your shell:

+
# bash — add to ~/.bashrc
-eval "$(coven-code completion bash)"
+complete -C coven coven
 
 # zsh — add to ~/.zshrc (requires compinit)
-eval "$(coven-code completion zsh)"
+compdef _gnu_generic coven

Uninstalling

-
rm -rf ~/.coven-code              # Linux / macOS
+    
npm uninstall -g @opencoven/coven
+# or
+bun remove -g @opencoven/coven
+
+rm -rf ~/.coven ~/.coven-code     # Linux / macOS
 
 # Windows (PowerShell):
-Remove-Item -Recurse -Force $env:USERPROFILE\\.coven-code
+Remove-Item -Recurse -Force $env:USERPROFILE\\.coven, $env:USERPROFILE\\.coven-code

See the full installation reference for cross-compiling to Linux aarch64, optional cargo features, and user-local installs without sudo.

`; diff --git a/docs/src/content/introduction.js b/docs/src/content/introduction.js index f2124c9..03744ea 100644 --- a/docs/src/content/introduction.js +++ b/docs/src/content/introduction.js @@ -7,7 +7,7 @@ export function render() {

You give Coven Code a task in natural language. It plans, reads and writes files, runs shell commands, searches the web, and iterates — all inside your terminal, with every step visible in real time.

-
$ coven-code "add input validation to the signup form"
+
$ coven run codex "add input validation to the signup form"

It reads your codebase, implements the change across multiple files, runs your tests, and reports back — without you leaving the terminal.

diff --git a/docs/src/demos.js b/docs/src/demos.js index a434ea5..ce9fccc 100644 --- a/docs/src/demos.js +++ b/docs/src/demos.js @@ -251,6 +251,7 @@ export function registerDemos(Alpine) { { id: '/config voice', category: 'Config', desc: 'Voice input mode' }, { id: '/config terminal-setup', category: 'Config', desc: 'Run terminal capability checks' }, { id: '/config keybindings', category: 'Config', desc: 'Open the interactive keybinding editor' }, + { id: '/splash', category: 'Config', desc: 'Show, hide, or toggle the welcome splash screen' }, { id: '/permissions', category: 'Config', desc: 'Manage tool permission rules' }, { id: '/hooks', category: 'Config', desc: 'Inspect active hooks' }, { id: '/mcp', category: 'Config', desc: 'Manage MCP servers' }, diff --git a/docs/src/hero.js b/docs/src/hero.js index 14aef66..0efa64a 100644 --- a/docs/src/hero.js +++ b/docs/src/hero.js @@ -21,7 +21,7 @@ export function renderHero(starCount) { ${starCount ? formatStars(starCount) : ''} diff --git a/docs/src/main.js b/docs/src/main.js index 8576115..4a85d4d 100644 --- a/docs/src/main.js +++ b/docs/src/main.js @@ -258,7 +258,7 @@ function bindCopyBtn() { const btn = document.getElementById('hero-copy-btn'); if (!btn) return; btn.addEventListener('click', () => { - navigator.clipboard.writeText('npm i -g coven-code').then(() => { + navigator.clipboard.writeText('npm i -g @opencoven/coven').then(() => { btn.querySelector('.hero-copy-icon').classList.add('hidden'); btn.querySelector('.hero-check-icon').classList.remove('hidden'); setTimeout(() => { diff --git a/docs/src/palette-data.js b/docs/src/palette-data.js index 57a2ef5..5208dbc 100644 --- a/docs/src/palette-data.js +++ b/docs/src/palette-data.js @@ -41,6 +41,7 @@ export const STATIC_PALETTE_ITEMS = [ ['/config voice', 'Config', 'Voice input mode'], ['/config terminal-setup','Config', 'Run terminal capability checks'], ['/config keybindings','Config', 'Open the interactive keybinding editor'], + ['/splash', 'Config', 'Show, hide, or toggle the welcome splash screen'], ['/permissions', 'Config', 'Manage tool permission rules'], ['/hooks', 'Config', 'Inspect active hooks'], ['/mcp', 'Config', 'Manage MCP servers'], diff --git a/docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md b/docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md new file mode 100644 index 0000000..cf0b33d --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md @@ -0,0 +1,173 @@ +# Inherited skills + interactive `/skills` picker + +Date: 2026-06-23 +Status: Approved (design), implementing + +## Problem + +coven-code only discovers skills under its own `.coven-code/` and `.agents/` +paths plus the in-binary bundled set. The standard Claude Code skill set +(brainstorming, using-superpowers, dispatching-parallel-agents, the higgsfield +family, …) lives under `~/.claude/` and in installed plugin repos, and OpenAI +Codex custom prompts live under `~/.codex/prompts/`. None of these surface in +coven-code today. We also lack the interactive, searchable, toggleable +`/skills` picker that other environments show. + +## Goals + +1. **Inherit skills from other environments.** Discover skills from Claude + (`~/.claude/skills/`, `.claude/skills/`, `~/.claude/plugins/*/skills/`), + Codex (`~/.codex/prompts/`), the coven plugin registry, and keep the existing + `.coven-code/` / `.agents/` / config-path / git-url sources — merged and + deduped by name. +2. **Interactive `/skills` picker** matching the reference UI: a search box, one + row per skill showing on/off state, scope label, and a `~NN tok` estimate, + with a selection cursor and scroll affordance. +3. **Persisted enable/disable.** Toggling a skill off persists and removes it + from both the model-facing skill list and the always-on skill index, so + disabling genuinely reclaims context tokens. + +## Non-goals + +- Running/invoking a skill from the picker (toggle-management only). +- A new fuzzy-match dependency (case-insensitive substring is enough, matching + the existing model picker). +- A real BPE tokenizer dependency (use the repo's existing estimation approach). + +## Design + +### 1. Skill discovery (`crates/core/src/skill_discovery.rs`) + +`DiscoveredSkill` gains: + +- `scope: SkillScope` — new enum `{ Project, User, Plugin }`. (Bundled + in-binary skills are not produced by `discover_skills`; the picker merges + them in separately and renders them with a `builtin` label — see §4.) +- `origin: String` — human label of where it came from: `"claude"`, `"codex"`, + `"coven"`, or the plugin directory name. Used only for diagnostics/tooltip; + the picker renders the `scope` word. +- `est_tokens: usize` — estimated always-on context cost (see §2). + +New roots scanned, in priority order (first-match-wins dedupe by name; a +higher-priority source keeps the entry): + +| Priority | Scope | Roots | +|----------|---------|-------| +| 1 | Project | `.coven-code/skills/`, `.agents/skills/`, `.claude/skills/` (walk up from cwd) | +| 2 | User | `~/.coven-code/skills/`, `~/.claude/skills/`, `~/.codex/prompts/` | +| 3 | Plugin | `~/.claude/plugins/*/skills/` and coven plugin-registry skill paths | +| — | Config | existing `SkillsConfig.paths` (User scope) and `urls` (User scope) | + +In-binary `BUNDLED_SKILLS` are not a `SkillScope` / not returned by +`discover_skills`; the picker merges them in at the listing layer and renders +them with a `builtin` label (see §4). + +Two on-disk layouts: + +- **Directory layout** (Claude / superpowers / plugins): a skill is a directory + containing `SKILL.md` with YAML frontmatter (`name`, `description`, optional + `when-to-use` / `when_to_use`). The skill name defaults to the directory name. + Scanning is one level deep per root: for each child dir, read `/SKILL.md`. +- **Flat layout** (existing coven `.md`, Codex `~/.codex/prompts/*.md`): a single + `.md` file. Codex prompts have no frontmatter → name = file stem, description = + first non-empty body line (truncated), the rest is the template. + +Implementation: `parse_skill_file` extended to also read `when_to_use`. A new +`scan_skill_root(dir, scope, origin)` handles both layouts and stamps +`scope`/`origin`. `discover_skills` adds the new roots. Existing tests keep +passing; new tests cover SKILL.md dirs, Codex flat prompts, and scope stamping. + +### 2. Token estimate + +The `~NN tok` figure is the **always-on cost** of the skill — the text injected +into the system prompt's skill index for it: `name + description + when_to_use`. +(This is why long-described skills like higgsfield read ~300+ while terse ones +read ~70.) Estimate with `est_skill_tokens(&DiscoveredSkill)` using the repo's +existing estimation convention from `token_budget.rs` (calibrated `chars/4`). +Rendered as `~{n} tok`. + +### 3. Persistence (`crates/core/src/lib.rs` `Settings`) + +Add: + +```rust +#[serde(default, rename = "disabledSkills")] +pub disabled_skills: std::collections::HashSet, +``` + +mirroring the existing `disabledPlugins`. A skill is enabled unless its name is +in this set. Toggling in the picker mutates the set and calls `save`. + +Filtering points: + +- `bundled_skills::user_invocable_skills()` and `skill_tool::list_skills()` skip + disabled names (model-facing list). +- Wherever the always-on skill index is built into the system prompt, skip + disabled names (token reclamation). Confirmed during implementation. + +### 4. Picker overlay (`crates/tui/src/skills_picker.rs`, new) + +Mirrors `effort_picker.rs` (modal/render) + `model_picker.rs` (filter box). + +```rust +pub struct SkillRow { + pub name: String, + pub scope_label: &'static str, // "user" | "project" | "plugin" | "builtin" + pub est_tokens: usize, + pub enabled: bool, +} + +pub struct SkillsPickerState { + pub visible: bool, + pub selected: usize, + pub filter: String, + pub scroll: usize, + pub rows: Vec, +} +``` + +- `open(rows)` populates and shows; `close()` hides. +- `filtered()` → indices whose name/scope contains the lowercased filter. +- Navigation clamps `selected` into the filtered set and adjusts `scroll`. +- Render: title ` Skills `; first body line is the search box + `⌕ Search skills…` (shows typed filter); then a viewport of rows: + `{› | space}{✓ on | ✗ off} {name} · {scope} · ~{tok} tok`, selected row in + accent. Footer shows `↑ N above` / `↓ N more below` when clipped and the + keybinding hint. +- Colors from `overlays.rs` palette (accent purple). + +### 5. Wiring + +- `App` gains `skills_picker: SkillsPickerState` (init in constructor). +- `render.rs`: render the picker after the effort_picker block. +- `handle_input`: guarded arm when `skills_picker.visible`: + typing/backspace edits filter; `↑/↓` + `Ctrl-p/n` navigate; `Space`/`Enter` + toggle the selected skill's enabled state (update `Settings.disabled_skills`, + save, update row); `Esc` closes. +- `/skills` command (`crates/commands/src/lib.rs`): with no args opens the + picker (builds rows from bundled + discovered, minus dedupe, marking enabled + from settings); `/skills ` opens pre-filtered. The model-facing + `SkillTool` `list` path is unchanged except for the disabled filter. + +Building rows requires the discovered-skill set + settings on the TUI side. The +command handler sets a signal/opens the picker via `App` (matching how +`/effort` calls `self.effort_picker.open(...)`). + +## Testing + +- Unit: `parse_skill_file` with `when_to_use`; `scan_skill_root` for both + layouts; `discover_skills` includes Claude/Codex/plugin roots and stamps + scope; dedupe priority; `est_skill_tokens` monotonicity. +- Unit: `SkillsPickerState` filter/navigation/toggle logic (pure, no TTY). +- `cargo build` + `cargo test` across the workspace. +- Manual: launch TUI, `/skills`, confirm inherited skills appear with scope and + token columns, toggle persists across restart. + +## Risks / notes + +- Plugin glob `~/.claude/plugins/*/skills/` — scan each immediate subdir of + `~/.claude/plugins/` for a `skills/` child. Cheap, bounded. +- `SKILL.md` bodies can be large; we only token-estimate metadata, not bodies, + so discovery stays cheap. +- 10 concurrent claude sessions on this checkout: additive commits on a + dedicated branch only — no rebases/force-pushes/pulls. diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 1e3cd9f..297c509 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -221,6 +221,7 @@ pub struct DiffCommand; pub struct GoalCommand; pub struct MemoryCommand; pub struct BugCommand; +pub struct LearnCommand; pub struct UsageCommand; pub struct DoctorCommand; pub struct LoginCommand; @@ -250,6 +251,7 @@ pub struct ReloadPluginsCommand; pub struct ThemeCommand; pub struct OutputStyleCommand; pub struct KeybindingsCommand; +pub struct SplashCommand; // Batch-1 new commands pub struct ContextCommand; pub struct CopyCommand; @@ -1114,6 +1116,68 @@ impl SlashCommand for ColorCommand { } } +// ---- /splash ------------------------------------------------------------- + +#[async_trait] +impl SlashCommand for SplashCommand { + fn name(&self) -> &str { + "splash" + } + + fn aliases(&self) -> Vec<&str> { + vec!["welcome-screen"] + } + + fn description(&self) -> &str { + "Show, hide, or toggle the empty-session splash screen" + } + + fn help(&self) -> &str { + "Usage: /splash [show|hide|toggle|status]\n\n\ + Controls the empty-session welcome/splash panel. With no argument,\n\ + toggles the current setting. The setting is persisted to\n\ + ~/.coven-code/settings.json." + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + let arg = args.trim().to_lowercase(); + let current = ctx.config.show_splash_enabled(); + + if arg == "status" { + return CommandResult::Message(format!( + "Splash screen is {}.\nUse /splash show, /splash hide, or /splash toggle.", + if current { "shown" } else { "hidden" } + )); + } + + let new_state = match arg.as_str() { + "" | "toggle" => !current, + "show" | "on" | "true" | "enable" | "enabled" => true, + "hide" | "off" | "false" | "disable" | "disabled" => false, + _ => { + return CommandResult::Error("Usage: /splash [show|hide|toggle|status]".to_string()) + } + }; + + let mut new_config = ctx.config.clone(); + new_config.show_splash = Some(new_state); + if let Err(err) = save_settings_mutation(|settings| { + settings.config.show_splash = Some(new_state); + }) { + return CommandResult::Error(format!("Failed to save splash setting: {}", err)); + } + + CommandResult::ConfigChangeMessage( + new_config, + if new_state { + "Splash screen shown.".to_string() + } else { + "Splash screen hidden. Run /splash show to restore it.".to_string() + }, + ) + } +} + // ---- /theme -------------------------------------------------------------- #[async_trait] @@ -3201,6 +3265,118 @@ impl SlashCommand for InitCommand { } } +// ---- /learn -------------------------------------------------------------- + +#[async_trait] +impl SlashCommand for LearnCommand { + fn name(&self) -> &str { + "learn" + } + fn aliases(&self) -> Vec<&str> { + vec!["scribe"] + } + fn description(&self) -> &str { + "Codify a script or workflow we just built into a reusable skill (Hermes)" + } + fn help(&self) -> &str { + "Usage: /learn [name] [path-or-description]\n\n\ + Summons Hermes — the coven's scribe — to watch the work that just\n\ + reached completion and transcribe its procedure into a reusable Skill.\n\ + The model distils the most recent script or workflow into a\n\ + `.coven-code/skills//SKILL.md` that future sessions can invoke as\n\ + `/` or via the Skill tool, and that you can toggle in /skills.\n\n\ + - /learn infer the target from this conversation\n\ + - /learn deploy-staging suggest the skill name explicitly\n\ + - /learn deploy scripts/deploy.sh name + the exact script to wrap\n\n\ + Hermes only authors the skill — it does not re-run the wrapped script." + } + + async fn execute(&self, args: &str, _ctx: &mut CommandContext) -> CommandResult { + let args = args.trim(); + + // The subject framing + step-1 instruction differ depending on whether + // the user pointed Hermes at a specific target or left it to infer one. + let (subject_block, locate_step) = if args.is_empty() { + ( + "No target was named, so infer it: the script or workflow we most \ + recently implemented together in THIS conversation." + .to_string(), + "Look back over this conversation for the most recent script or \ + workflow we built. Inspect `git diff`, the files edited earlier in \ + the session, and anything under `scripts/` to pin down the exact \ + entrypoint — the command line, file path, and arguments it takes." + .to_string(), + ) + } else { + ( + format!( + "The user pointed Hermes at: `{args}`. Treat this as the skill \ + name and/or the path or description of the script/workflow to wrap." + ), + format!( + "Resolve `{args}` to a concrete script or workflow: if it is a \ + path, read that file; if it is a name or description, find the \ + matching work we just implemented (check `git diff`, recently \ + edited files, and `scripts/`). Pin down the exact entrypoint — \ + command line, file path, and arguments." + ), + ) + }; + + let prompt = format!( + "🪶 **/learn — Hermes, scribe of the coven.** Codify what we just built \ + into a reusable skill.\n\n\ + \n\ + Act as Hermes: the messenger who watches a piece of work reach \ + completion and transcribes its procedure into a portable, reusable \ + Skill that future sessions — and the model itself — can invoke on \ + demand. You are not re-doing the work; you are distilling it into a \ + durable artifact.\n\ + \n\n\ + \n{subject_block}\n\n\n\ + Follow these steps exactly:\n\n\ + 1. **Identify the script/workflow.** {locate_step} Then restate, in one \ + or two sentences, what it does and the concrete way it is invoked.\n\n\ + 2. **Name it.** Choose a short kebab-case skill `name`, a single-line \ + `description` (this lives in always-on context — keep it tight), and a \ + `when-to-use` trigger describing the situations that should reach for it.\n\n\ + 3. **Author the skill file** at `.coven-code/skills//SKILL.md` \ + (create the directory if needed) with this exact shape:\n\n\ + ```\n\ + ---\n\ + name: \n\ + description: \n\ + when-to-use: \n\ + ---\n\n\ + # \n\n\ + <2-4 sentence overview of what running this skill accomplishes.>\n\n\ + ## Steps\n\ + 1. <exact command(s) to run, with the real paths and flags>\n\ + ...\n\n\ + ## Inputs\n\ + <Use the literal token $ARGUMENTS where the skill should accept \ + runtime arguments.>\n\n\ + ## Guardrails\n\ + - <preconditions, what NOT to do, how to verify success>\n\ + ```\n\n\ + Bake the real command, paths, and flags from step 1 into the body — \ + not placeholders. A fresh session with no other context should be able \ + to run the script correctly from this file alone.\n\n\ + 4. **Verify discovery.** Confirm the file parses as a skill: \ + frontmatter delimited by `---`, with `name` and `description` present. \ + It is auto-discovered under the project `.coven-code/skills/` root.\n\n\ + 5. **Report.** Print the final path and a one-line summary. Tell the \ + user they can invoke it as `/<name>` (it joins the skill set on the next \ + launch), that the model can call it via the Skill tool, and that \ + `/skills` lets them toggle it or inspect its token cost.\n\n\ + Do NOT run the wrapped script now — Hermes only writes the skill. Keep \ + the frontmatter `description` short to respect the always-on context budget." + ); + + CommandResult::UserMessage(prompt) + } +} + // ---- /review ------------------------------------------------------------- #[async_trait] @@ -9707,6 +9883,7 @@ static COMMANDS: Lazy<Vec<Box<dyn SlashCommand>>> = Lazy::new(|| { Box::new(RefreshCommand), Box::new(IncantCommand), Box::new(InitCommand), + Box::new(LearnCommand), Box::new(ReviewCommand), Box::new(HooksCommand), Box::new(ImportConfigCommand), @@ -9720,6 +9897,7 @@ static COMMANDS: Lazy<Vec<Box<dyn SlashCommand>>> = Lazy::new(|| { Box::new(ThemeCommand), Box::new(OutputStyleCommand), Box::new(KeybindingsCommand), + Box::new(SplashCommand), // New commands Box::new(ExportCommand), Box::new(ShareCommand), @@ -10116,6 +10294,43 @@ mod tests { } } + #[tokio::test] + async fn test_learn_resolves_and_emits_skill_prompt() { + // Resolvable by name and by alias. + assert!(find_command("learn").is_some()); + assert!(find_command("scribe").is_some()); + + let mut ctx = make_ctx(); + + // No args → infer the target from the conversation. + let result = LearnCommand.execute("", &mut ctx).await; + let CommandResult::UserMessage(prompt) = result else { + panic!("expected UserMessage"); + }; + assert!(prompt.contains("Hermes"), "names the Hermes persona"); + assert!( + prompt.contains(".coven-code/skills/<name>/SKILL.md"), + "writes to the discoverable skill path" + ); + assert!(prompt.contains("when-to-use"), "instructs full frontmatter"); + assert!( + prompt.contains("No target was named"), + "empty args → infer-from-conversation framing" + ); + + // Explicit target → echoed into both the subject and locate steps. + let result = LearnCommand + .execute("deploy scripts/deploy.sh", &mut ctx) + .await; + let CommandResult::UserMessage(prompt) = result else { + panic!("expected UserMessage"); + }; + assert!( + prompt.contains("scripts/deploy.sh"), + "interpolates the user-supplied target" + ); + } + #[test] fn test_find_command_by_name() { assert!(find_command("help").is_some()); @@ -10126,6 +10341,45 @@ mod tests { assert!(find_command("version").is_some()); } + #[tokio::test] + async fn splash_command_hides_and_persists_welcome_screen() { + let _guard = CommandEnvGuard::with_coven_home(None); + let mut ctx = make_ctx(); + ctx.config.show_splash = Some(true); + + let command = find_command("splash").expect("/splash command"); + let result = command.execute("hide", &mut ctx).await; + + match result { + CommandResult::ConfigChangeMessage(config, message) => { + assert_eq!(config.show_splash, Some(false)); + assert!(message.contains("hidden")); + } + other => panic!("expected ConfigChangeMessage, got {other:?}"), + } + + let settings = Settings::load_sync().expect("settings load"); + assert_eq!(settings.config.show_splash, Some(false)); + } + + #[tokio::test] + async fn splash_command_toggles_by_default() { + let _guard = CommandEnvGuard::with_coven_home(None); + let mut ctx = make_ctx(); + ctx.config.show_splash = Some(true); + + let command = find_command("splash").expect("/splash command"); + let result = command.execute("", &mut ctx).await; + + match result { + CommandResult::ConfigChangeMessage(config, message) => { + assert_eq!(config.show_splash, Some(false)); + assert!(message.contains("hidden")); + } + other => panic!("expected ConfigChangeMessage, got {other:?}"), + } + } + #[test] fn test_find_command_with_slash_prefix() { // find_command should strip the leading / before lookup diff --git a/src-rust/crates/commands/src/named_commands.rs b/src-rust/crates/commands/src/named_commands.rs index b50f552..befcee5 100644 --- a/src-rust/crates/commands/src/named_commands.rs +++ b/src-rust/crates/commands/src/named_commands.rs @@ -154,6 +154,12 @@ impl NamedCommand for AgentsCommand { } "create" => { let name = args.get(1).copied().unwrap_or("my-agent"); + if claurst_core::coven_shared::is_disallowed_familiar_name(name) { + return CommandResult::Error(format!( + "The name '{name}' is reserved and cannot be used for an agent or \ + familiar. Choose a different name." + )); + } CommandResult::Message(format!( "Create a new agent by adding .coven-code/agents/{name}.md\n\ Template:\n\ @@ -174,6 +180,13 @@ impl NamedCommand for AgentsCommand { ) } }; + // Block renaming/retargeting an agent onto a reserved name. + if claurst_core::coven_shared::is_disallowed_familiar_name(name) { + return CommandResult::Error(format!( + "The name '{name}' is reserved and cannot be used for an agent or \ + familiar. Choose a different name." + )); + } CommandResult::Message(format!( "Edit .coven-code/agents/{name}.md in your editor to update the agent." )) diff --git a/src-rust/crates/core/src/coven_shared.rs b/src-rust/crates/core/src/coven_shared.rs index 0427aaa..2c6e918 100644 --- a/src-rust/crates/core/src/coven_shared.rs +++ b/src-rust/crates/core/src/coven_shared.rs @@ -92,6 +92,48 @@ pub fn resolve_access_tier(input: &str) -> &'static str { } } +/// Agent names that are strictly disallowed for **any** agent, regardless of +/// source (built-ins, project settings, `~/.coven/familiars.toml`, or runtime +/// creation). +/// +/// Compared case-insensitively after trimming. A matching id never enters the +/// runtime agent map, so the Agent tool cannot resolve it and the mode +/// switcher never lists it. Callers go through [`is_disallowed_agent_name`]. +pub const DISALLOWED_AGENT_NAMES: &[&str] = &["val", "vale", "valentina"]; + +/// Names that are strictly disallowed for **familiars** specifically (new or +/// existing). These are in addition to [`DISALLOWED_AGENT_NAMES`], which apply +/// to every agent kind. A workspace agent under `.coven-code/agents/` may +/// still use one of these names — only Coven familiars are blocked. +/// +/// Compared case-insensitively after trimming, via +/// [`is_disallowed_familiar_name`]. +pub const DISALLOWED_FAMILIAR_NAMES: &[&str] = + &["nova", "echo", "sage", "kitty", "cody", "charm", "astra"]; + +/// Whether `name` is a strictly disallowed name for any agent. +/// +/// Trims surrounding whitespace and compares case-insensitively against +/// [`DISALLOWED_AGENT_NAMES`]. Used at every point where an agent name can +/// enter the system (merge, load, and explicit creation) so the block holds +/// no matter where the name originates. +pub fn is_disallowed_agent_name(name: &str) -> bool { + let normalized = name.trim().to_ascii_lowercase(); + DISALLOWED_AGENT_NAMES.contains(&normalized.as_str()) +} + +/// Whether `name` is a strictly disallowed name for a **familiar**. +/// +/// Covers both [`DISALLOWED_FAMILIAR_NAMES`] and the broader +/// [`DISALLOWED_AGENT_NAMES`] (anything banned for all agents is also banned +/// for familiars). Applied wherever a familiar enters the system so the +/// reserved names can never surface as a resolvable familiar. +pub fn is_disallowed_familiar_name(name: &str) -> bool { + let normalized = name.trim().to_ascii_lowercase(); + DISALLOWED_FAMILIAR_NAMES.contains(&normalized.as_str()) + || is_disallowed_agent_name(&normalized) +} + /// One entry in `~/.coven/familiars.toml`. /// /// Schema mirrors what the daemon serves at `GET /api/v1/familiars`. @@ -204,9 +246,15 @@ pub fn default_agents_with_familiars( if let Some(fams) = load_familiars() { for fam in &fams { let (id, def) = familiar_to_agent_definition(fam); + // Familiars get the broader familiar ban (reserved familiar names + // plus the all-agent ban). + if is_disallowed_familiar_name(&id) { + continue; + } map.entry(id).or_insert(def); } } + map.retain(|id, _| !is_disallowed_agent_name(id)); map } @@ -229,6 +277,9 @@ pub fn default_agents_with_familiars_and_config( // reserved built-in. This is the security boundary that // `resolve_tui_agent_mode` and the Tab cycle in the TUI rely on. for (id, def) in config_agents { + if is_disallowed_agent_name(id) { + continue; + } if !builtins.contains_key(id) { map.insert(id.clone(), def.clone()); } @@ -237,12 +288,21 @@ pub fn default_agents_with_familiars_and_config( if let Some(fams) = load_familiars() { for fam in &fams { let (id, def) = familiar_to_agent_definition(fam); + // Familiars get the broader familiar ban (reserved familiar names + // plus the all-agent ban). + if is_disallowed_familiar_name(&id) { + continue; + } if !builtins.contains_key(&id) { map.insert(id, def); } } } + // Final guard: a disallowed name must never survive in the runtime map, + // even if it somehow slipped in as a built-in or via display-name aliasing. + map.retain(|id, _| !is_disallowed_agent_name(id)); + map } @@ -479,8 +539,8 @@ access = "search-only" home.join("familiars.toml"), r#" [[familiar]] -id = "cody" -display_name = "Cody" +id = "willow" +display_name = "Willow" role = "Code" access = "full" @@ -495,8 +555,11 @@ access = "search-only" let merged = default_agents_with_familiars(); // Built-in `build` is untouched. assert_eq!(merged.get("build").map(|d| d.access.as_str()), Some("full")); - // Familiar `cody` was merged in with its declared access. - assert_eq!(merged.get("cody").map(|d| d.access.as_str()), Some("full")); + // Familiar `willow` was merged in with its declared access. + assert_eq!( + merged.get("willow").map(|d| d.access.as_str()), + Some("full") + ); } #[test] @@ -506,8 +569,8 @@ access = "search-only" home.join("familiars.toml"), r#" [[familiar]] -id = "cody" -display_name = "Cody" +id = "willow" +display_name = "Willow" role = "Code" "#, ) @@ -516,7 +579,7 @@ role = "Code" let mut config_agents = std::collections::HashMap::new(); config_agents.insert( - "cody".to_string(), + "willow".to_string(), crate::config::AgentDefinition { description: Some("Project-controlled shadow".to_string()), model: None, @@ -530,10 +593,10 @@ role = "Code" ); let merged = default_agents_with_familiars_and_config(&config_agents); - let cody = merged.get("cody").expect("familiar should be present"); - assert_eq!(cody.access, DEFAULT_FAMILIAR_ACCESS); - let prompt = cody.prompt.as_deref().unwrap_or_default(); - assert!(prompt.contains("Cody")); + let willow = merged.get("willow").expect("familiar should be present"); + assert_eq!(willow.access, DEFAULT_FAMILIAR_ACCESS); + let prompt = willow.prompt.as_deref().unwrap_or_default(); + assert!(prompt.contains("Willow")); assert!(!prompt.contains("Run shell commands")); } @@ -631,8 +694,8 @@ access = "full" home.join("familiars.toml"), r#" [[familiar]] -id = "sage" -display_name = "Sage" +id = "willow" +display_name = "Willow" role = "Research" access = "read-only" "#, @@ -642,12 +705,91 @@ access = "read-only" let config_agents = std::collections::HashMap::new(); let merged = default_agents_with_familiars_and_config(&config_agents); - let sage = merged.get("sage").expect("sage familiar should be present"); - assert_eq!(sage.access, "read-only"); - let prompt = sage.prompt.as_deref().unwrap_or_default(); + let willow = merged + .get("willow") + .expect("willow familiar should be present"); + assert_eq!(willow.access, "read-only"); + let prompt = willow.prompt.as_deref().unwrap_or_default(); assert!(prompt.contains("Research")); } + #[test] + fn disallowed_names_never_enter_runtime_agent_map() { + // A familiar claiming a reserved agent name (val) and reserved familiar + // names (sage / cody) must be filtered out of the merged runtime map, + // while a genuinely free name survives. + let _g = with_coven_home(|home| { + fs::write( + home.join("familiars.toml"), + r#" +[[familiar]] +id = "val" +display_name = "Val" +access = "full" + +[[familiar]] +id = "sage" +display_name = "Sage" +access = "full" + +[[familiar]] +id = "cody" +display_name = "Cody" +access = "full" + +[[familiar]] +id = "willow" +display_name = "Willow" +access = "read-only" +"#, + ) + .unwrap(); + }); + + // A project setting also cannot smuggle in the reserved agent name. + let mut config_agents = std::collections::HashMap::new(); + config_agents.insert( + "valentina".to_string(), + crate::config::AgentDefinition { + description: Some("shadow".to_string()), + model: None, + temperature: None, + prompt: Some("shadow".to_string()), + access: "full".to_string(), + visible: true, + max_turns: None, + color: None, + }, + ); + + let merged = default_agents_with_familiars_and_config(&config_agents); + assert!(!merged.contains_key("val"), "val (reserved agent) filtered"); + assert!( + !merged.contains_key("valentina"), + "valentina (reserved agent, via settings) filtered" + ); + assert!( + !merged.contains_key("sage"), + "sage (reserved familiar) filtered" + ); + assert!( + !merged.contains_key("cody"), + "cody (reserved familiar) filtered" + ); + assert!(merged.contains_key("willow"), "free name survives"); + } + + #[test] + fn disallowed_name_helpers_are_case_insensitive() { + assert!(is_disallowed_agent_name("Val")); + assert!(is_disallowed_agent_name(" VALENTINA ")); + assert!(!is_disallowed_agent_name("sage")); // familiar-only ban + assert!(is_disallowed_familiar_name("Sage")); + assert!(is_disallowed_familiar_name("ECHO")); + assert!(is_disallowed_familiar_name("val")); // agent ban ⊆ familiar ban + assert!(!is_disallowed_familiar_name("willow")); + } + #[test] fn canonicalize_access_tier_accepts_canonical_lowercase() { assert_eq!(canonicalize_access_tier("full"), Some("full")); diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 8c1ee31..51f6202 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -30,8 +30,8 @@ pub mod device_code; // Utility modules ported from src/utils/ pub mod auto_mode; pub mod crypto_utils; -pub mod secret_file; pub mod format_utils; +pub mod secret_file; pub mod spinner; pub mod status_notices; pub mod token_budget; @@ -98,7 +98,9 @@ pub use types::{ // Skill discovery: filesystem and git URL skill loading. pub mod skill_discovery; -pub use skill_discovery::{discover_skills, parse_skill_file, DiscoveredSkill}; +pub use skill_discovery::{ + discover_skills, estimate_tokens, parse_skill_file, DiscoveredSkill, SkillScope, +}; // Coven daemon shared state — read-only bridge to ~/.coven/. pub mod coven_shared; @@ -992,6 +994,13 @@ pub mod config { /// Defaults to "kitty" (the Coven Code cat). Set via `"familiar": "nova"` in settings.json. #[serde(default)] pub familiar: Option<String>, + /// Whether to show the empty-session welcome/splash panel. Defaults to true. + #[serde( + default, + rename = "showSplash", + skip_serializing_if = "Option::is_none" + )] + pub show_splash: Option<bool>, /// Skill-discovery configuration (copied from Settings on load). #[serde(default)] pub skills: SkillsConfig, @@ -1118,6 +1127,11 @@ pub mod config { /// Names of plugins that have been explicitly disabled by the user. #[serde(default, rename = "disabledPlugins")] pub disabled_plugins: std::collections::HashSet<String>, + /// Names of skills the user has toggled off in the `/skills` picker. + /// Disabled skills are excluded from the model-facing skill list and + /// the always-on skill index so they no longer consume context tokens. + #[serde(default, rename = "disabledSkills")] + pub disabled_skills: std::collections::HashSet<String>, /// Whether the user has completed the first-launch onboarding flow. /// Mirrors TS `hasAcknowledgedSafetyNotice` / `hasCompletedOnboarding`. #[serde(default, rename = "hasCompletedOnboarding")] @@ -1217,6 +1231,11 @@ pub mod config { pub fn completion_toast_enabled(&self) -> bool { self.completion_toast.unwrap_or(true) } + + /// Whether a skill is enabled (not toggled off in the `/skills` picker). + pub fn is_skill_enabled(&self, name: &str) -> bool { + !self.disabled_skills.contains(name) + } } /// A user-defined slash command template. @@ -1295,6 +1314,11 @@ pub mod config { } impl Config { + /// Whether the empty-session welcome/splash panel should render. + pub fn show_splash_enabled(&self) -> bool { + self.show_splash.unwrap_or(true) + } + pub fn selected_provider_id(&self) -> &str { self.provider .as_deref() @@ -1776,6 +1800,7 @@ pub mod config { commands: merge_map(base.config.commands, over.config.commands), agents: merge_map(base.config.agents, over.config.agents), familiar: over.config.familiar.or(base.config.familiar), + show_splash: over.config.show_splash.or(base.config.show_splash), skills: { let mut paths = base.config.skills.paths; for p in over.config.skills.paths { @@ -1833,6 +1858,11 @@ pub mod config { s.extend(over.disabled_plugins); s }, + disabled_skills: { + let mut s = base.disabled_skills; + s.extend(over.disabled_skills); + s + }, has_completed_onboarding: over.has_completed_onboarding || base.has_completed_onboarding, last_seen_version: over.last_seen_version.or(base.last_seen_version), diff --git a/src-rust/crates/core/src/skill_discovery.rs b/src-rust/crates/core/src/skill_discovery.rs index 6fd15f9..81bb465 100644 --- a/src-rust/crates/core/src/skill_discovery.rs +++ b/src-rust/crates/core/src/skill_discovery.rs @@ -1,12 +1,23 @@ //! Skill discovery: load custom prompt-template skills from markdown files //! on disk and (optionally) from git URLs. //! -//! Search priority (first match wins for a given skill name): -//! 1. Project `.coven-code/skills/` — walk up from `cwd` -//! 2. Project `.agents/skills/` — walk up from `cwd` -//! 3. Global `~/.coven-code/skills/` +//! Skills are inherited from several environments so coven-code surfaces the +//! same skill set as the surrounding tooling. Search priority (first match +//! wins for a given skill name): +//! 1. Project (walk up from `cwd`): +//! `.coven-code/skills/`, `.agents/skills/`, `.claude/skills/` +//! 2. User (home dir): +//! `~/.coven-code/skills/`, `~/.claude/skills/`, `~/.codex/prompts/` +//! 3. Plugin: `~/.claude/plugins/*/skills/` //! 4. Configured extra paths from `SkillsConfig.paths` //! 5. Git-URL repos from `SkillsConfig.urls` (cloned once, then cached) +//! +//! Two on-disk layouts are supported per root: +//! - **Directory layout** (`<name>/SKILL.md`) — used by Claude / superpowers +//! plugins. Frontmatter `name` / `description` / `when-to-use`. +//! - **Flat layout** (`<name>.md`) — used by coven skills and Codex prompts +//! (`~/.codex/prompts/`). Codex prompts have no frontmatter, so the file +//! stem is the name and the first body line is the description. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -15,17 +26,70 @@ use std::path::{Path, PathBuf}; // Public types // --------------------------------------------------------------------------- +/// Where a discovered skill came from. Drives the scope label shown in the +/// `/skills` picker (`builtin` is reserved for in-binary bundled skills, which +/// are not produced by this module). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillScope { + /// Found under a project directory (walked up from cwd). + Project, + /// Found under the user's home directory. + User, + /// Contributed by an installed plugin. + Plugin, +} + +impl SkillScope { + /// Short word rendered in the picker (`project` / `user` / `plugin`). + pub fn label(self) -> &'static str { + match self { + SkillScope::Project => "project", + SkillScope::User => "user", + SkillScope::Plugin => "plugin", + } + } +} + +/// Rough token estimate for a string, for display only (not an exact count). +/// +/// Delegates to the canonical [`crate::message_utils::estimate_tokens`] so the +/// token heuristic stays single-sourced and user-facing counts don't drift. +pub fn estimate_tokens(text: &str) -> usize { + crate::message_utils::estimate_tokens(text) as usize +} + /// A discovered skill loaded from a markdown file. #[derive(Debug, Clone)] pub struct DiscoveredSkill { - /// Skill name (from `name:` frontmatter or file stem). + /// Skill name (from `name:` frontmatter or file stem / dir name). pub name: String, - /// One-line description (from `description:` frontmatter or default). + /// One-line description (from `description:` frontmatter or first body line). pub description: String, + /// Optional "when to use" guidance from frontmatter. + pub when_to_use: Option<String>, /// The prompt body after stripping frontmatter. pub template: String, /// Absolute path to the source `.md` file. pub source_path: PathBuf, + /// Which environment this skill was inherited from. + pub scope: SkillScope, + /// Human label of the origin (`coven`, `claude`, `codex`, or plugin name). + pub origin: String, + /// Estimated always-on context cost (name + description + when-to-use). + pub est_tokens: usize, +} + +impl DiscoveredSkill { + /// Recompute `est_tokens` from the current metadata fields. + fn recompute_tokens(&mut self) { + let meta = format!( + "{} {} {}", + self.name, + self.description, + self.when_to_use.as_deref().unwrap_or("") + ); + self.est_tokens = estimate_tokens(&meta); + } } // --------------------------------------------------------------------------- @@ -34,67 +98,156 @@ pub struct DiscoveredSkill { /// Parse a skill markdown file. /// -/// Expects optional YAML frontmatter delimited by `---`. -/// Returns `None` when the file is empty after trimming. +/// Expects optional YAML frontmatter delimited by `---`. When `description` +/// is absent (e.g. Codex prompts), it falls back to the first non-empty body +/// line (with any leading `#` heading markers stripped). Returns `None` when +/// the file is empty after trimming. +/// +/// `scope` / `origin` are stamped to defaults here; the scanning layer +/// (`scan_skill_root`) overrides them for each root. pub fn parse_skill_file(content: &str, path: &Path) -> Option<DiscoveredSkill> { let content = content.trim(); if content.is_empty() { return None; } - let (name, description, template) = if let Some(after_open) = content.strip_prefix("---") { - // Accept both `\n---` and `\r\n---` as closing delimiter. - if let Some(close_pos) = after_open.find("\n---") { - let frontmatter = &after_open[..close_pos]; - let rest = after_open[close_pos + 4..].trim_start_matches(['\r', '\n']); - - let mut name: Option<String> = None; - let mut description: Option<String> = None; - - for line in frontmatter.lines() { - let line = line.trim(); - if let Some(v) = line.strip_prefix("name:") { - name = Some(v.trim().trim_matches('"').trim_matches('\'').to_string()); - } else if let Some(v) = line.strip_prefix("description:") { - description = Some(v.trim().trim_matches('"').trim_matches('\'').to_string()); - } + let (name, description, when_to_use, template) = + if let Some(after_open) = content.strip_prefix("---") { + // Accept both `\n---` and `\r\n---` as closing delimiter. + if let Some(close_pos) = after_open.find("\n---") { + let frontmatter = &after_open[..close_pos]; + let rest = after_open[close_pos + 4..].trim_start_matches(['\r', '\n']); + let (name, description, when_to_use) = parse_frontmatter(frontmatter); + (name, description, when_to_use, rest.to_string()) + } else { + // Malformed frontmatter — treat entire content as template. + (None, None, None, content.to_string()) } - - (name, description, rest.to_string()) } else { - // Malformed frontmatter — treat entire content as template. - (None, None, content.to_string()) - } - } else { - (None, None, content.to_string()) - }; + (None, None, None, content.to_string()) + }; - let name = name.unwrap_or_else(|| { + // Treat a present-but-empty `name:` as missing so it can't become an + // empty HashMap key that breaks dedupe/lookup; fall back to the file stem. + let name = name.filter(|n| !n.is_empty()).unwrap_or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("unnamed") .to_string() }); - let description = description.unwrap_or_else(|| "Custom skill".to_string()); + // Fall back to the first meaningful body line so frontmatter-less skills + // (Codex prompts, bare `.md` files) still get a useful one-liner. + let description = description + .filter(|d| !d.is_empty()) + .or_else(|| first_body_line(&template)) + .unwrap_or_else(|| "Custom skill".to_string()); if template.is_empty() && name.is_empty() { return None; } - Some(DiscoveredSkill { + let mut skill = DiscoveredSkill { name, description, + when_to_use, template, source_path: path.to_path_buf(), - }) + scope: SkillScope::User, + origin: String::new(), + est_tokens: 0, + }; + skill.recompute_tokens(); + Some(skill) +} + +/// Strip surrounding single/double quotes and whitespace from a frontmatter +/// value. +fn unquote(v: &str) -> String { + v.trim().trim_matches('"').trim_matches('\'').to_string() +} + +/// Parse the YAML frontmatter for `name`, `description`, and `when-to-use`, +/// handling both inline values (`description: text`) and block scalars +/// (`description: |` / `>` followed by indented continuation lines, as used by +/// the Claude/superpowers skill set). Block scalars are folded to a single +/// space-joined line — enough for display and token estimation. +fn parse_frontmatter(frontmatter: &str) -> (Option<String>, Option<String>, Option<String>) { + let lines: Vec<&str> = frontmatter.lines().collect(); + let mut name = None; + let mut description = None; + let mut when_to_use = None; + + let mut i = 0; + while i < lines.len() { + let raw = lines[i]; + let indent = raw.len() - raw.trim_start().len(); + i += 1; + + let Some((key, val)) = raw.trim().split_once(':') else { + continue; + }; + let key = key.trim(); + if !matches!(key, "name" | "description" | "when-to-use" | "when_to_use") { + continue; + } + let val = val.trim(); + + // Block scalar: empty value or a `|` / `>` indicator → gather the + // following lines that are indented deeper than this key. + let value = if val.is_empty() || val.starts_with('|') || val.starts_with('>') { + let mut parts: Vec<String> = Vec::new(); + while i < lines.len() { + let cont = lines[i]; + if cont.trim().is_empty() { + i += 1; + continue; + } + let cont_indent = cont.len() - cont.trim_start().len(); + if cont_indent > indent { + parts.push(cont.trim().to_string()); + i += 1; + } else { + break; + } + } + parts.join(" ") + } else { + unquote(val) + }; + + match key { + "name" => name = Some(value), + "description" => description = Some(value), + _ => when_to_use = Some(value), + } + } + + (name, description, when_to_use) +} + +/// First non-empty line of a body, with any leading `#` heading markers +/// stripped, truncated to 200 chars. +fn first_body_line(body: &str) -> Option<String> { + for line in body.lines() { + let t = line.trim().trim_start_matches('#').trim(); + if !t.is_empty() { + let truncated: String = t.chars().take(200).collect(); + return Some(truncated); + } + } + None } // --------------------------------------------------------------------------- // Directory scanning // --------------------------------------------------------------------------- -/// Scan a single directory for `*.md` skill files. -fn scan_dir(dir: &Path) -> Vec<DiscoveredSkill> { +/// Scan a single skill root, handling both layouts: +/// - flat `<name>.md` files directly in `dir` +/// - `<name>/SKILL.md` directories (Claude / superpowers / plugins) +/// +/// Each returned skill is stamped with `scope` and `origin`. +fn scan_skill_root(dir: &Path, scope: SkillScope, origin: &str) -> Vec<DiscoveredSkill> { let mut skills = Vec::new(); if !dir.is_dir() { return skills; @@ -110,23 +263,53 @@ fn scan_dir(dir: &Path) -> Vec<DiscoveredSkill> { for entry in entries.flatten() { let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("md") { - match std::fs::read_to_string(&path) { - Ok(content) => { - if let Some(skill) = parse_skill_file(&content, &path) { - skills.push(skill); + let parsed = if path.is_dir() { + // Directory layout: look for `SKILL.md` (or lowercase `skill.md`) + // and default the name to the directory name. + let skill_md = path.join("SKILL.md"); + let skill_md = if skill_md.is_file() { + Some(skill_md) + } else { + let alt = path.join("skill.md"); + alt.is_file().then_some(alt) + }; + skill_md.and_then(|p| read_and_parse(&p)).map(|mut s| { + // Prefer the directory name when frontmatter omitted `name:`. + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + if s.name == "SKILL" || s.name == "skill" { + s.name = dir_name.to_string(); + s.recompute_tokens(); } } - Err(err) => { - tracing::debug!(path = %path.display(), error = %err, "skill_discovery: read failed"); - } - } + s + }) + } else if path.extension().and_then(|e| e.to_str()) == Some("md") { + read_and_parse(&path) + } else { + None + }; + + if let Some(mut skill) = parsed { + skill.scope = scope; + skill.origin = origin.to_string(); + skills.push(skill); } } skills } +/// Read a file and parse it into a skill, logging read failures. +fn read_and_parse(path: &Path) -> Option<DiscoveredSkill> { + match std::fs::read_to_string(path) { + Ok(content) => parse_skill_file(&content, path), + Err(err) => { + tracing::debug!(path = %path.display(), error = %err, "skill_discovery: read failed"); + None + } + } +} + // --------------------------------------------------------------------------- // Top-level discovery // --------------------------------------------------------------------------- @@ -134,7 +317,7 @@ fn scan_dir(dir: &Path) -> Vec<DiscoveredSkill> { /// Discover all skills from all configured sources. /// /// Returns a `HashMap` of `skill_name → DiscoveredSkill` (first match wins; -/// duplicates from lower-priority sources are warned via `tracing::warn`). +/// duplicates from lower-priority sources are logged at debug level). pub fn discover_skills( cwd: &Path, config_skills: &crate::config::SkillsConfig, @@ -142,7 +325,7 @@ pub fn discover_skills( let mut all: HashMap<String, DiscoveredSkill> = HashMap::new(); let mut warn_duplicates: Vec<String> = Vec::new(); - // Inline closure: insert a batch, warning on duplicates. + // Inline closure: insert a batch, warning on duplicates (first wins). let mut add = |skills: Vec<DiscoveredSkill>| { for skill in skills { if let Some(existing) = all.get(&skill.name) { @@ -158,12 +341,32 @@ pub fn discover_skills( } }; + let home = dirs::home_dir(); + // ---- 1. Project skills: walk up from cwd -------------------------------- + // Stop at the home directory so home-level skill dirs are attributed to the + // User scope below (not "project") when the checkout lives under $HOME. { let mut dir: &Path = cwd; loop { - add(scan_dir(&dir.join(".coven-code").join("skills"))); - add(scan_dir(&dir.join(".agents").join("skills"))); + if home.as_deref() == Some(dir) { + break; + } + add(scan_skill_root( + &dir.join(".coven-code").join("skills"), + SkillScope::Project, + "coven", + )); + add(scan_skill_root( + &dir.join(".agents").join("skills"), + SkillScope::Project, + "agents", + )); + add(scan_skill_root( + &dir.join(".claude").join("skills"), + SkillScope::Project, + "claude", + )); match dir.parent() { Some(parent) if parent != dir => dir = parent, _ => break, @@ -171,12 +374,52 @@ pub fn discover_skills( } } - // ---- 2. Global skills: ~/.coven-code/skills/ -------------------------------- - if let Some(home) = dirs::home_dir() { - add(scan_dir(&home.join(".coven-code").join("skills"))); + // ---- 2. User skills: home directory ------------------------------------- + if let Some(home) = home { + add(scan_skill_root( + &home.join(".coven-code").join("skills"), + SkillScope::User, + "coven", + )); + add(scan_skill_root( + &home.join(".agents").join("skills"), + SkillScope::User, + "agents", + )); + add(scan_skill_root( + &home.join(".claude").join("skills"), + SkillScope::User, + "claude", + )); + // OpenAI Codex custom prompts (flat `.md`, no frontmatter). + add(scan_skill_root( + &home.join(".codex").join("prompts"), + SkillScope::User, + "codex", + )); + + // ---- 3. Plugin skills: ~/.claude/plugins/*/skills/ ----------------- + let plugins_root = home.join(".claude").join("plugins"); + if let Ok(entries) = std::fs::read_dir(&plugins_root) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + let origin = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("plugin") + .to_string(); + add(scan_skill_root( + &p.join("skills"), + SkillScope::Plugin, + &origin, + )); + } + } + } } - // ---- 3. Configured extra paths ------------------------------------------ + // ---- 4. Configured extra paths ------------------------------------------ for path_str in &config_skills.paths { let path = Path::new(path_str); let path = if path.is_absolute() { @@ -184,19 +427,21 @@ pub fn discover_skills( } else { cwd.join(path) }; - add(scan_dir(&path)); + add(scan_skill_root(&path, SkillScope::User, "config")); } - // ---- 4. Git URL skills (cached) ----------------------------------------- + // ---- 5. Git URL skills (cached) ----------------------------------------- for url in &config_skills.urls { if let Some(git_skills) = fetch_git_skills(url) { add(git_skills); } } - // Emit warnings for any duplicate skill names encountered. + // Duplicates are expected now that skills are inherited from several + // overlapping environments (e.g. the same skill in ~/.claude and + // ~/.agents), so log them at debug level rather than warn. for w in &warn_duplicates { - tracing::warn!("{}", w); + tracing::debug!("{}", w); } all @@ -258,8 +503,12 @@ fn fetch_git_skills(url: &str) -> Option<Vec<DiscoveredSkill>> { } // Scan repo root and optional `skills/` subdirectory. - let mut skills = scan_dir(&repo_dir); - skills.extend(scan_dir(&repo_dir.join("skills"))); + let mut skills = scan_skill_root(&repo_dir, SkillScope::User, repo_name); + skills.extend(scan_skill_root( + &repo_dir.join("skills"), + SkillScope::User, + repo_name, + )); Some(skills) } @@ -299,13 +548,75 @@ mod tests { } #[test] - fn test_parse_no_frontmatter_uses_stem() { + fn test_parse_no_frontmatter_uses_stem_and_first_line() { + // Codex-style flat prompt: no frontmatter, description from first line. let content = "Do something useful."; let path = PathBuf::from("my-skill.md"); let skill = parse_skill_file(content, &path).unwrap(); assert_eq!(skill.name, "my-skill"); - assert_eq!(skill.description, "Custom skill"); + assert_eq!(skill.description, "Do something useful."); assert_eq!(skill.template, "Do something useful."); + assert!(skill.est_tokens > 0); + } + + #[test] + fn test_parse_when_to_use_frontmatter() { + let content = + "---\nname: brainstorm\ndescription: Explore ideas\nwhen-to-use: before any creative work\n---\nBody."; + let skill = parse_skill_file(content, &PathBuf::from("x.md")).unwrap(); + assert_eq!( + skill.when_to_use.as_deref(), + Some("before any creative work") + ); + } + + #[test] + fn test_parse_block_scalar_description() { + // Mirrors the Claude/superpowers `description: |` block-scalar layout. + let content = "---\nversion: 0.3.0\nname: higgs\ndescription: |\n First line of the description.\n Second line continues here.\n---\n# Body\nstuff"; + let skill = parse_skill_file(content, &PathBuf::from("higgs/SKILL.md")).unwrap(); + assert_eq!(skill.name, "higgs"); + assert_eq!( + skill.description, + "First line of the description. Second line continues here." + ); + // Token estimate should reflect the full folded description, not just "|". + assert!(skill.est_tokens >= 12, "got {}", skill.est_tokens); + } + + #[test] + fn test_estimate_tokens_monotonic() { + assert!(estimate_tokens("a much longer string here") > estimate_tokens("short")); + assert_eq!(estimate_tokens(""), 0); + } + + #[test] + fn test_scan_skill_root_directory_layout() { + let tmp = make_temp_dir(); + // <root>/brainstorming/SKILL.md (name omitted → dir name used) + write_file( + tmp.path(), + "brainstorming/SKILL.md", + "---\ndescription: Turn ideas into designs\n---\nDo the thing.", + ); + let skills = scan_skill_root(tmp.path(), SkillScope::Plugin, "superpowers"); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "brainstorming"); + assert_eq!(skills[0].scope, SkillScope::Plugin); + assert_eq!(skills[0].origin, "superpowers"); + assert_eq!(skills[0].description, "Turn ideas into designs"); + } + + #[test] + fn test_discover_stamps_project_scope() { + let tmp = make_temp_dir(); + let skills_dir = tmp.path().join(".coven-code").join("skills"); + std::fs::create_dir_all(&skills_dir).unwrap(); + write_file(&skills_dir, "myskill.md", "---\nname: myskill\n---\nDo it."); + + let config = crate::config::SkillsConfig::default(); + let discovered = discover_skills(tmp.path(), &config); + assert_eq!(discovered["myskill"].scope, SkillScope::Project); } #[test] @@ -344,7 +655,7 @@ mod tests { write_file(tmp.path(), "debug.md", "Debug help."); write_file(tmp.path(), "not-md.txt", "ignored"); - let skills = scan_dir(tmp.path()); + let skills = scan_skill_root(tmp.path(), SkillScope::User, ""); assert_eq!(skills.len(), 2); let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); assert!(names.contains(&"review")); @@ -353,7 +664,7 @@ mod tests { #[test] fn test_scan_dir_nonexistent_returns_empty() { - let skills = scan_dir(Path::new("/nonexistent/path/xyz")); + let skills = scan_skill_root(Path::new("/nonexistent/path/xyz"), SkillScope::User, ""); assert!(skills.is_empty()); } diff --git a/src-rust/crates/query/src/skill_prefetch.rs b/src-rust/crates/query/src/skill_prefetch.rs index 4812a79..f35dfb6 100644 --- a/src-rust/crates/query/src/skill_prefetch.rs +++ b/src-rust/crates/query/src/skill_prefetch.rs @@ -71,6 +71,14 @@ pub type SharedSkillIndex = Arc<RwLock<SkillIndex>>; pub async fn prefetch_skills(project_root: &Path, index: SharedSkillIndex) { let mut local = SkillIndex::default(); + // Skills the user toggled off in the `/skills` picker are excluded from the + // always-on index so they no longer consume context tokens. Use the async + // loader since this runs on the Tokio runtime. + let disabled = claurst_core::config::Settings::load() + .await + .map(|s| s.disabled_skills) + .unwrap_or_default(); + // 1. User-defined skills: ~/.coven-code/skills/*.md + {project_root}/.coven-code/skills/*.md let search_dirs: Vec<std::path::PathBuf> = { let mut dirs = Vec::new(); @@ -87,7 +95,9 @@ pub async fn prefetch_skills(project_root: &Path, index: SharedSkillIndex) { let path = entry.path(); if path.extension().is_some_and(|e| e == "md") { if let Some(skill) = load_skill_from_file(&path) { - local.insert(skill); + if !disabled.contains(&skill.name) { + local.insert(skill); + } } } } @@ -107,7 +117,9 @@ pub async fn prefetch_skills(project_root: &Path, index: SharedSkillIndex) { if path.extension().is_some_and(|e| e == "md") { if let Some(mut skill) = load_skill_from_file(&path) { skill.source = "bundled".to_string(); - local.insert(skill); + if !disabled.contains(&skill.name) { + local.insert(skill); + } } } } diff --git a/src-rust/crates/tools/src/skill_tool.rs b/src-rust/crates/tools/src/skill_tool.rs index 8343ebe..5582f21 100644 --- a/src-rust/crates/tools/src/skill_tool.rs +++ b/src-rust/crates/tools/src/skill_tool.rs @@ -154,9 +154,19 @@ fn skill_search_dirs(ctx: &ToolContext) -> Vec<PathBuf> { } async fn list_skills(dirs: &[PathBuf]) -> ToolResult { - // Start with the bundled skills. + // Skills toggled off in the `/skills` picker are hidden from the model. + // Use the async loader so we don't block the runtime from this async fn. + let disabled = claurst_core::config::Settings::load() + .await + .map(|s| s.disabled_skills) + .unwrap_or_default(); + + // Start with the bundled skills (minus any the user disabled). let mut lines: Vec<String> = Vec::new(); - let bundled = user_invocable_skills(); + let bundled: Vec<(&str, &str)> = user_invocable_skills() + .into_iter() + .filter(|(name, _)| !disabled.contains(*name)) + .collect(); for (name, desc) in &bundled { lines.push(format!(" {} — {} [bundled]", name, desc)); } @@ -172,9 +182,10 @@ async fn list_skills(dirs: &[PathBuf]) -> ToolResult { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { let name = stem.to_string(); // Deduplicate — project-level shadows user-level; - // bundled skills shadow everything. + // bundled skills shadow everything; disabled skills hide. if !disk_skills.iter().any(|(n, _)| n == &name) && !bundled_names.contains(&name.as_str()) + && !disabled.contains(&name) { disk_skills.push((name, path)); } diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index 019abe4..829ae44 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -517,6 +517,11 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec<AgentDefini let path = entry.path(); if path.extension().is_some_and(|e| e == "md") { if let Some(def) = parse_agent_def(&path) { + // Reserved names are limited to the user; never surface a + // workspace agent that claims one. + if coven_shared::is_disallowed_agent_name(&def.name) { + continue; + } defs.push(def); } } @@ -537,6 +542,13 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec<AgentDefini for fam in &familiars { let display = fam.display_name.as_deref().unwrap_or(&fam.id).to_string(); + // Reserved familiar names are limited to the user — never surface a + // familiar that claims one, by id or display name. + if coven_shared::is_disallowed_familiar_name(&fam.id) + || coven_shared::is_disallowed_familiar_name(&display) + { + continue; + } // Skip if user already defined an agent with the same display name. if familiar_names.contains(&display.to_lowercase()) { continue; diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index d0fcb90..052a38a 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -91,6 +91,10 @@ pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ "Cast a speech incantation (caveman, rocky) or lift it with off", ), ("init", "Initialize AGENTS.md for this project"), + ( + "learn", + "Codify the script/workflow we just built into a reusable skill", + ), ("login", "Log in, switch accounts, or refresh provider auth"), ("logout", "Log out of Coven Code"), ("mcp", "Browse configured MCP servers"), @@ -154,7 +158,9 @@ pub fn slash_command_category(name: &str) -> &'static str { "Conversation" } "model" | "config" | "settings" | "theme" | "color" | "vim" | "fast" | "effort" - | "voice" | "statusline" | "output-style" | "keybindings" | "sandbox" => "Settings", + | "voice" | "statusline" | "output-style" | "keybindings" | "splash" | "sandbox" => { + "Settings" + } "cost" | "usage" | "context" | "stats" => "Usage & Cost", "status" | "doctor" | "terminal-setup" | "version" | "update" | "upgrade" => "System", "login" | "logout" | "switch" | "refresh" | "permissions" | "connect" | "providers" => { @@ -167,7 +173,9 @@ pub fn slash_command_category(name: &str) -> &'static str { "session" | "resume" | "search" | "share" | "rename" => "Sessions & Remote", "help" | "exit" | "quit" | "feedback" | "survey" | "bug" => "General", "think-back" | "thinking" | "plan" | "goal" | "tasks" | "advisor" => "AI & Thinking", - "copy" | "skills" | "plugin" | "reload-plugins" | "whisper" | "incant" => "Tools & Extras", + "copy" | "skills" | "learn" | "plugin" | "reload-plugins" | "whisper" | "incant" => { + "Tools & Extras" + } "coven" | "familiar" | "familiars" | "handoff" => "Coven", _ => "Other", } @@ -1314,6 +1322,8 @@ pub struct App { pub onboarding_dialog: crate::onboarding_dialog::OnboardingDialogState, /// Effort-level picker (/effort with no args). pub effort_picker: crate::effort_picker::EffortPickerState, + /// Interactive skills picker (/skills). + pub skills_picker: crate::skills_picker::SkillsPickerState, /// API key input dialog (opened from /connect for key-based providers). pub key_input_dialog: crate::key_input_dialog::KeyInputDialogState, /// Custom provider dialog for URL + API key input. @@ -1798,6 +1808,7 @@ impl App { file_injection_force: false, onboarding_dialog: crate::onboarding_dialog::OnboardingDialogState::new(), effort_picker: crate::effort_picker::EffortPickerState::new(), + skills_picker: crate::skills_picker::SkillsPickerState::new(), key_input_dialog: crate::key_input_dialog::KeyInputDialogState::new(), custom_provider_dialog: crate::custom_provider_dialog::CustomProviderDialogState::new(), free_mode_dialog: crate::free_mode_dialog::FreeModeDialogState::new(), @@ -2492,6 +2503,11 @@ impl App { ("effort", "fast", _) => return self.intercept_slash_command("fast"), _ => {} } + // `/skills` opens the interactive picker; `/skills <query>` pre-filters. + if cmd == "skills" { + self.open_skills_picker(args_trimmed); + return true; + } if !args_trimmed.is_empty() && matches!(cmd, "config" | "settings" | "usage") { return false; } @@ -2879,7 +2895,16 @@ impl App { self.theme_screen.close(); } - pub fn any_modal_open(&self) -> bool { + /// Whether a *blocking* overlay currently owns keyboard input — i.e. the + /// prompt is not the active text target. + /// + /// This is the single source of truth for "an overlay captures input": any + /// new picker/dialog added here automatically stops prompt text (including + /// paste-burst capture) from leaking while it is open — see + /// [`Self::prompt_is_accepting_text`]. It excludes the passive notices + /// (overage / voice / memory), which render as overlays but let the user + /// keep typing underneath. + pub fn any_blocking_modal_open(&self) -> bool { self.permission_request.is_some() || self.rewind_flow.visible || self.tasks_overlay.visible @@ -2895,9 +2920,6 @@ impl App { || self.feedback_survey.visible || self.memory_file_selector.visible || self.hooks_config_menu.visible - || self.overage_upsell.visible - || self.voice_mode_notice.visible - || self.memory_update_notification.visible || self.import_config_dialog.visible || self.invalid_config_dialog.visible || self.bypass_permissions_dialog.visible @@ -2918,9 +2940,19 @@ impl App { || self.context_viz.visible || self.mcp_approval.visible || self.file_injection_dialog.visible + || self.skills_picker.visible || self.context_menu_state.is_some() } + pub fn any_modal_open(&self) -> bool { + // Passive notices don't capture input but still render as overlays, so + // they count as a modal for rendering purposes only. + self.any_blocking_modal_open() + || self.overage_upsell.visible + || self.voice_mode_notice.visible + || self.memory_update_notification.visible + } + fn dismiss_error_notifications(&mut self) { while self.notifications.current_is_error() { self.notifications.dismiss_current(); @@ -2959,6 +2991,41 @@ impl App { .unwrap_or_else(|| std::path::PathBuf::from(".")) } + /// Build the skill list from settings + cwd and open the `/skills` picker. + fn open_skills_picker(&mut self, filter: &str) { + let settings = Settings::load_sync().unwrap_or_default(); + let cwd = self.project_root(); + let rows = crate::skills_picker::build_skill_rows(&cwd, &settings); + self.skills_picker.open(rows, filter); + } + + /// Toggle the selected skill's enabled state and persist it to settings. + fn toggle_selected_skill(&mut self) { + if let Some((name, enabled)) = self.skills_picker.toggle_selected() { + let mut settings = Settings::load_sync().unwrap_or_default(); + if enabled { + settings.disabled_skills.remove(&name); + } else { + settings.disabled_skills.insert(name.clone()); + } + match settings.save_sync() { + Ok(()) => { + self.status_message = Some(format!( + "Skill '{}' {}.", + name, + if enabled { "enabled" } else { "disabled" } + )); + } + Err(e) => { + // Persisting failed — revert the in-memory toggle so the + // on-screen state stays consistent with disk, and report it. + self.skills_picker.toggle_selected(); + self.status_message = Some(format!("Failed to save skill '{}': {}", name, e)); + } + } + } + } + fn refresh_global_search(&mut self) { let root = self.project_root(); self.global_search.run_search(&root); @@ -3696,6 +3763,23 @@ impl App { return false; } + // Skills picker dialog (/skills). + if self.skills_picker.visible { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => self.skills_picker.close(), + KeyCode::Up => self.skills_picker.select_prev(), + KeyCode::Down => self.skills_picker.select_next(), + KeyCode::Char('p') if ctrl => self.skills_picker.select_prev(), + KeyCode::Char('n') if ctrl => self.skills_picker.select_next(), + KeyCode::Char(' ') | KeyCode::Enter => self.toggle_selected_skill(), + KeyCode::Backspace => self.skills_picker.pop_filter_char(), + KeyCode::Char(c) if !ctrl => self.skills_picker.push_filter_char(c), + _ => {} + } + return false; + } + // Effort picker dialog (/effort). if self.effort_picker.visible { match key.code { @@ -6360,12 +6444,12 @@ impl App { /// Returns `true` when the app is in a state where the prompt can accept /// regular text input — used to gate paste-burst detection. pub fn prompt_is_accepting_text(&self) -> bool { + // Gate on the shared blocking-modal predicate rather than a parallel + // hand-list: any overlay that captures input (skills/model pickers, + // command palette, dialogs, …) automatically blocks prompt text and + // paste-burst capture while it is open. !self.is_streaming - && self.permission_request.is_none() - && !self.ask_user_dialog.visible - && !self.history_search_overlay.visible - && !self.settings_screen.visible - && !self.theme_screen.visible + && !self.any_blocking_modal_open() && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert } diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index 0f047d4..0c30ee7 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -165,6 +165,8 @@ pub mod session_branching; pub mod session_browser; /// Full-screen tabbed settings interface. pub mod settings_screen; +/// Interactive skills picker dialog (/skills). +pub mod skills_picker; /// Stats dialog with token usage and cost charts. pub mod stats_dialog; /// Task progress overlay (Ctrl+T) — shows task status with inline toggle. diff --git a/src-rust/crates/tui/src/onboarding_dialog.rs b/src-rust/crates/tui/src/onboarding_dialog.rs index 8377fba..6bc5076 100644 --- a/src-rust/crates/tui/src/onboarding_dialog.rs +++ b/src-rust/crates/tui/src/onboarding_dialog.rs @@ -187,6 +187,7 @@ fn render_provider_setup_page(frame: &mut Frame, area: Rect) { // Theme pink — matches the header and mascot let pink = Color::Rgb(139, 92, 246); let dim = Color::Rgb(100, 100, 100); + let esc_red = Color::Red; let block = Block::default() .borders(Borders::ALL) @@ -202,6 +203,19 @@ fn render_provider_setup_page(frame: &mut Frame, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); + Paragraph::new(Line::from(Span::styled( + " esc ", + Style::default().fg(esc_red).add_modifier(Modifier::BOLD), + ))) + .render( + Rect { + x: area.x + area.width.saturating_sub(6), + y: area.y, + width: 5, + height: 1, + }, + frame.buffer_mut(), + ); let sep = " ─────────────────────────────────────────────────"; @@ -577,17 +591,38 @@ mod tests { render_onboarding_dialog(frame, &state, frame.area()); }) .unwrap(); - let content: String = terminal - .backend() - .buffer() - .clone() + let buffer = terminal.backend().buffer().clone(); + let width = buffer.area.width as usize; + let rows: Vec<String> = buffer .content() - .iter() - .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .chunks(width) + .map(|row| { + row.iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect() + }) .collect(); + let content = rows.join("\n"); // Free Mode hint is present and precedes every provider. let free = content.find("Free Mode").expect("Free Mode hint missing"); assert!(content.contains("/connect")); + let (title_y, title_row) = rows + .iter() + .enumerate() + .find(|(_, line)| line.contains("Connect a Provider")) + .expect("provider setup title row missing"); + assert!( + title_row.contains("esc"), + "provider setup title should render an esc hint in the top corner, got {title_row:?}" + ); + let esc_byte = title_row + .find("esc") + .expect("provider setup title row missing esc hint"); + let esc_x = title_row[..esc_byte].chars().count(); + for offset in 0..3 { + let cell = &buffer.content()[title_y * width + esc_x + offset]; + assert_eq!(cell.fg, Color::Red, "provider setup esc hint should be red"); + } // Neutral ordering: no-key local provider first, then alphabetical. let positions: Vec<usize> = ["Ollama", "Anthropic", "Google", "Groq", "OpenAI"] .iter() diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index b60f105..a242bf8 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -684,6 +684,11 @@ pub fn render_app(frame: &mut Frame, app: &App) { crate::effort_picker::render_effort_picker(frame, &app.effort_picker, size); } + // /skills picker + if app.skills_picker.visible { + crate::skills_picker::render_skills_picker(frame, &app.skills_picker, size); + } + // Import-config source picker if app.import_config_picker.visible { render_dialog_select(frame, &app.import_config_picker, size); @@ -1065,7 +1070,9 @@ fn render_messages(frame: &mut Frame, app: &App, area: Rect) { let notice_lines = startup_notice_lines(app, content_area.width); let header_height = WELCOME_BOX_HEIGHT + notice_lines.len() as u16; - let show_logo_header = content_area.height >= header_height + 3 && content_area.width >= 60; + let show_splash = app.config.show_splash_enabled(); + let show_logo_header = + show_splash && content_area.height >= header_height + 3 && content_area.width >= 60; let (logo_area, notices_area, msg_area) = if show_logo_header { let splits = Layout::default() .direction(Direction::Vertical) @@ -1092,7 +1099,8 @@ fn render_messages(frame: &mut Frame, app: &App, area: Rect) { if let Some(na) = notices_area { render_startup_notices(frame, app, na); } - } else if app.messages.is_empty() + } else if show_splash + && app.messages.is_empty() && app.streaming_text.is_empty() && app.streaming_thinking.is_empty() && app.tool_use_blocks.is_empty() @@ -3620,6 +3628,28 @@ mod welcome_tests { } } + #[test] + fn welcome_screen_can_be_hidden_by_config() { + let mut terminal = Terminal::new(TestBackend::new(100, 28)).expect("terminal"); + let mut app = make_test_app_with_model_and_familiar(None, None, None, None); + app.config.show_splash = Some(false); + + terminal + .draw(|frame| render_app(frame, &app)) + .expect("draw app"); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::<String>(); + + assert!(!rendered.contains("Welcome back")); + assert!(!rendered.contains("What's new")); + } + #[test] fn friendly_model_label_maps_known_families() { assert_eq!(friendly_model_from_id("claude-opus-4-8"), "Opus 4.8"); @@ -3635,7 +3665,10 @@ mod welcome_tests { "Opus 4.8 (1M context)" ); // Unknown ids pass through untouched. - assert_eq!(friendly_model_from_id("some-other-model"), "some-other-model"); + assert_eq!( + friendly_model_from_id("some-other-model"), + "some-other-model" + ); } #[test] diff --git a/src-rust/crates/tui/src/skills_picker.rs b/src-rust/crates/tui/src/skills_picker.rs new file mode 100644 index 0000000..299f9d2 --- /dev/null +++ b/src-rust/crates/tui/src/skills_picker.rs @@ -0,0 +1,376 @@ +// skills_picker.rs — Interactive `/skills` overlay. +// +// Lists every skill coven-code can see — bundled (in-binary) plus skills +// inherited from other environments (coven, Claude `~/.claude/skills` and +// plugins, Codex `~/.codex/prompts`) — with a search box, a per-skill on/off +// toggle, scope label, and an estimated always-on token cost. +// +// Toggling persists to `Settings.disabled_skills`, which the skill index and +// the model-facing skill list both honour, so disabling a skill reclaims the +// context tokens it would otherwise spend. + +use std::path::Path; + +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::overlays::{ + begin_modal_frame, modal_header_line_area, modal_search_line, render_modal_title_frame, + COVEN_CODE_ACCENT, COVEN_CODE_MUTED, COVEN_CODE_TEXT, +}; + +/// One row in the skills picker. +#[derive(Debug, Clone)] +pub struct SkillRow { + pub name: String, + pub scope_label: &'static str, + pub est_tokens: usize, + pub enabled: bool, +} + +/// State for the `/skills` picker overlay. +#[derive(Debug, Default)] +pub struct SkillsPickerState { + pub visible: bool, + /// Index into the *filtered* row list. + pub selected: usize, + pub filter: String, + pub rows: Vec<SkillRow>, +} + +impl SkillsPickerState { + pub fn new() -> Self { + Self::default() + } + + /// Open the picker with the given rows, optionally pre-filtered. + pub fn open(&mut self, rows: Vec<SkillRow>, filter: &str) { + self.rows = rows; + self.filter = filter.to_string(); + self.selected = 0; + self.visible = true; + } + + pub fn close(&mut self) { + self.visible = false; + self.filter.clear(); + } + + /// Indices into `self.rows` matching the current filter (name or scope). + pub fn filtered_indices(&self) -> Vec<usize> { + if self.filter.is_empty() { + return (0..self.rows.len()).collect(); + } + let needle = self.filter.to_lowercase(); + self.rows + .iter() + .enumerate() + .filter(|(_, r)| { + r.name.to_lowercase().contains(needle.as_str()) + || r.scope_label.contains(needle.as_str()) + }) + .map(|(i, _)| i) + .collect() + } + + pub fn push_filter_char(&mut self, c: char) { + self.filter.push(c); + self.selected = 0; + } + + pub fn pop_filter_char(&mut self) { + self.filter.pop(); + self.selected = 0; + } + + pub fn select_prev(&mut self) { + let count = self.filtered_indices().len(); + if count == 0 { + return; + } + self.selected = if self.selected == 0 { + count - 1 + } else { + self.selected - 1 + }; + } + + pub fn select_next(&mut self) { + let count = self.filtered_indices().len(); + if count == 0 { + return; + } + self.selected = (self.selected + 1) % count; + } + + /// Toggle the enabled state of the selected row. + /// + /// Returns `(name, now_enabled)` so the caller can persist the change. + pub fn toggle_selected(&mut self) -> Option<(String, bool)> { + let filtered = self.filtered_indices(); + let &row_idx = filtered.get(self.selected)?; + let row = self.rows.get_mut(row_idx)?; + row.enabled = !row.enabled; + Some((row.name.clone(), row.enabled)) + } +} + +/// Assemble the full skill list from every visible source. +/// +/// Bundled (in-binary) skills take precedence over same-named disk skills, +/// mirroring `SkillTool`'s resolution order. +pub fn build_skill_rows(cwd: &Path, settings: &claurst_core::config::Settings) -> Vec<SkillRow> { + let mut rows: Vec<SkillRow> = Vec::new(); + let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); + + // 1. Bundled, user-invocable skills. + for skill in claurst_tools::bundled_skills::BUNDLED_SKILLS { + if !skill.user_invocable || !seen.insert(skill.name.to_string()) { + continue; + } + let meta = format!( + "{} {} {}", + skill.name, + skill.description, + skill.when_to_use.unwrap_or("") + ); + rows.push(SkillRow { + name: skill.name.to_string(), + scope_label: "builtin", + est_tokens: claurst_core::estimate_tokens(&meta), + enabled: settings.is_skill_enabled(skill.name), + }); + } + + // 2. Skills inherited from disk / other environments. + let discovered = claurst_core::discover_skills(cwd, &settings.skills); + let mut disk: Vec<_> = discovered.into_values().collect(); + disk.sort_by(|a, b| a.name.cmp(&b.name)); + for skill in disk { + if !seen.insert(skill.name.clone()) { + continue; + } + rows.push(SkillRow { + name: skill.name.clone(), + scope_label: skill.scope.label(), + est_tokens: skill.est_tokens, + enabled: settings.is_skill_enabled(&skill.name), + }); + } + + rows.sort_by(|a, b| a.name.cmp(&b.name)); + rows +} + +pub fn render_skills_picker(frame: &mut Frame, state: &SkillsPickerState, area: Rect) { + if !state.visible { + return; + } + + let width = 76u16.min(area.width.saturating_sub(4)); + let height = ((state.rows.len() as u16) + 7) + .min((area.height as f32 * 0.8) as u16) + .max(10); + let layout = begin_modal_frame(frame, area, width, height, 3, 2); + render_modal_title_frame(frame, layout.header_area, "Skills", "esc"); + + let search = modal_search_line( + &state.filter, + "Search skills…", + COVEN_CODE_MUTED, + COVEN_CODE_TEXT, + ); + if let Some(search_area) = modal_header_line_area(layout.header_area, 2) { + frame.render_widget(Paragraph::new(search), search_area); + } + + let body = layout.body_area; + if body.height == 0 { + return; + } + + let filtered = state.filtered_indices(); + let total = filtered.len(); + let view_h = body.height as usize; + + let mut lines: Vec<Line> = Vec::new(); + + if total == 0 { + lines.push(Line::from(Span::styled( + " No matching skills", + Style::default().fg(COVEN_CODE_MUTED), + ))); + } + + // Window the filtered rows so the selection stays visible. + let sel = state.selected.min(total.saturating_sub(1)); + let start = if total <= view_h { + 0 + } else { + sel.saturating_sub(view_h / 2).min(total - view_h) + }; + let end = (start + view_h).min(total); + + for (vis_i, &row_idx) in filtered[start..end].iter().enumerate() { + let is_selected = start + vis_i == sel; + let row = &state.rows[row_idx]; + + let cursor = if is_selected { "›" } else { " " }; + let (state_glyph, state_color) = if row.enabled { + ("✓ on ", Color::Green) + } else { + ("✗ off", COVEN_CODE_MUTED) + }; + let name_style = if is_selected { + Style::default() + .fg(COVEN_CODE_ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(COVEN_CODE_TEXT) + }; + + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", cursor), + Style::default().fg(COVEN_CODE_ACCENT), + ), + Span::styled( + format!("{} ", state_glyph), + Style::default().fg(state_color), + ), + Span::styled(row.name.clone(), name_style), + Span::styled( + format!(" · {} · ~{} tok", row.scope_label, row.est_tokens), + Style::default().fg(COVEN_CODE_MUTED), + ), + ])); + } + + frame.render_widget(Paragraph::new(lines), body); + + // Footer: scroll status (line 0) + keybinds (line 1). + let above = start; + let below = total.saturating_sub(end); + let mut scroll_parts: Vec<String> = Vec::new(); + if above > 0 { + scroll_parts.push(format!("↑ {} above", above)); + } + if below > 0 { + scroll_parts.push(format!("↓ {} more below", below)); + } + let footer_lines = vec![ + Line::from(Span::styled( + format!(" {}", scroll_parts.join(" ")), + Style::default().fg(COVEN_CODE_MUTED), + )), + Line::from(Span::styled( + " ↑↓ navigate · space/enter toggle · type to filter · esc close", + Style::default() + .fg(COVEN_CODE_MUTED) + .add_modifier(Modifier::ITALIC), + )), + ]; + frame.render_widget(Paragraph::new(footer_lines), layout.footer_area); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec<SkillRow> { + vec![ + SkillRow { + name: "brainstorming".into(), + scope_label: "user", + est_tokens: 70, + enabled: true, + }, + SkillRow { + name: "debug".into(), + scope_label: "builtin", + est_tokens: 40, + enabled: true, + }, + SkillRow { + name: "higgsfield-generate".into(), + scope_label: "user", + est_tokens: 330, + enabled: false, + }, + ] + } + + #[test] + fn filter_matches_name_and_scope() { + let mut s = SkillsPickerState::new(); + s.open(rows(), ""); + assert_eq!(s.filtered_indices().len(), 3); + for c in "higgs".chars() { + s.push_filter_char(c); + } + assert_eq!(s.filtered_indices().len(), 1); + s.filter.clear(); + for c in "builtin".chars() { + s.push_filter_char(c); + } + assert_eq!(s.filtered_indices().len(), 1); + } + + #[test] + fn navigation_wraps_over_filtered() { + let mut s = SkillsPickerState::new(); + s.open(rows(), ""); + s.selected = 0; + s.select_prev(); + assert_eq!(s.selected, 2); + s.select_next(); + assert_eq!(s.selected, 0); + } + + #[test] + fn toggle_flips_and_reports() { + let mut s = SkillsPickerState::new(); + s.open(rows(), ""); + s.selected = 0; // brainstorming, currently enabled + let res = s.toggle_selected(); + assert_eq!(res, Some(("brainstorming".to_string(), false))); + assert!(!s.rows[0].enabled); + } + + #[test] + fn render_matches_screenshot_layout() { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + let mut s = SkillsPickerState::new(); + s.open(rows(), ""); + let backend = TestBackend::new(100, 30); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| render_skills_picker(f, &s, f.area())) + .unwrap(); + + let rendered = term + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect::<String>(); + + for needle in [ + "Skills", + "Search skills", + "brainstorming", + "on", + "off", + "user", + "builtin", + "tok", + "toggle", + ] { + assert!(rendered.contains(needle), "render missing {needle:?}"); + } + } +}