From 6243dc8be1253787ba2270b7c205f0c259d9086a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:32:05 -0500 Subject: [PATCH 01/10] feat(tui): inherited skills + interactive /skills picker Discover skills from other environments (Claude ~/.claude/skills and ~/.claude/plugins/*/skills, Codex ~/.codex/prompts, project .claude/skills) alongside the existing coven/agents sources, deduped by name with a scope label and an estimated always-on token cost. Handles both the directory layout (/SKILL.md) and flat .md prompts, including YAML block-scalar descriptions. /skills now opens an interactive, searchable picker (skills_picker.rs) showing each skill's on/off state, scope, and ~NN tok estimate. Space/Enter toggles a skill; the choice persists to Settings.disabledSkills and is honoured by both the always-on skill index (skill_prefetch) and the model-facing skill list (skill_tool), so disabling reclaims context tokens. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-23-inherited-skills-picker-design.md | 168 +++++++ src-rust/crates/core/src/lib.rs | 19 +- src-rust/crates/core/src/skill_discovery.rs | 409 +++++++++++++++--- src-rust/crates/query/src/skill_prefetch.rs | 14 +- src-rust/crates/tools/src/skill_tool.rs | 15 +- src-rust/crates/tui/src/app.rs | 85 ++++ src-rust/crates/tui/src/lib.rs | 2 + src-rust/crates/tui/src/render.rs | 13 +- src-rust/crates/tui/src/skills_picker.rs | 372 ++++++++++++++++ 9 files changed, 1028 insertions(+), 69 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md create mode 100644 src-rust/crates/tui/src/skills_picker.rs 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..d3777fa --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md @@ -0,0 +1,168 @@ +# 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 `{ Bundled, Project, User, Plugin }`. +- `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 | +| 4 | Bundled | in-binary `BUNDLED_SKILLS` (merged at the listing layer, not in this fn) | +| — | Config | existing `SkillsConfig.paths` (User scope) and `urls` (User scope) | + +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/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 8c1ee31..75ac33f 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -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; @@ -1118,6 +1120,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, + /// 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, /// Whether the user has completed the first-launch onboarding flow. /// Mirrors TS `hasAcknowledgedSafetyNotice` / `hasCompletedOnboarding`. #[serde(default, rename = "hasCompletedOnboarding")] @@ -1217,6 +1224,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. @@ -1833,6 +1845,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..c2a4dda 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** (`/SKILL.md`) — used by Claude / superpowers +//! plugins. Frontmatter `name` / `description` / `when-to-use`. +//! - **Flat layout** (`.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,68 @@ 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, mirroring the `chars / 4` convention +/// used elsewhere in the codebase (see `token_budget`). +pub fn estimate_tokens(text: &str) -> usize { + text.chars().count().div_ceil(4) +} + /// 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, /// 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,40 +96,33 @@ 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, +/// non-heading body line. 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 { 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 = None; - let mut description: Option = 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(|| { path.file_stem() @@ -75,26 +130,118 @@ pub fn parse_skill_file(content: &str, path: &Path) -> Option { .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, Option, Option) { + 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 = 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, non-heading line of a body, truncated to 200 chars. +fn first_body_line(body: &str) -> Option { + 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 { +/// Scan a single skill root, handling both layouts: +/// - flat `.md` files directly in `dir` +/// - `/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 { let mut skills = Vec::new(); if !dir.is_dir() { return skills; @@ -110,23 +257,54 @@ fn scan_dir(dir: &Path) -> Vec { 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 (case-insensitive on the stem) + // 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 { + 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 // --------------------------------------------------------------------------- @@ -142,7 +320,7 @@ pub fn discover_skills( let mut all: HashMap = HashMap::new(); let mut warn_duplicates: Vec = 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| { for skill in skills { if let Some(existing) = all.get(&skill.name) { @@ -162,8 +340,21 @@ pub fn discover_skills( { let mut dir: &Path = cwd; loop { - add(scan_dir(&dir.join(".coven-code").join("skills"))); - add(scan_dir(&dir.join(".agents").join("skills"))); + 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 +362,47 @@ pub fn discover_skills( } } - // ---- 2. Global skills: ~/.coven-code/skills/ -------------------------------- + // ---- 2. User skills: home directory ------------------------------------- if let Some(home) = dirs::home_dir() { - add(scan_dir(&home.join(".coven-code").join("skills"))); + add(scan_skill_root( + &home.join(".coven-code").join("skills"), + SkillScope::User, + "coven", + )); + 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,10 +410,10 @@ 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); @@ -258,8 +484,12 @@ fn fetch_git_skills(url: &str) -> Option> { } // 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 +529,72 @@ 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(); + // /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 +633,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 +642,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..9c66f52 100644 --- a/src-rust/crates/query/src/skill_prefetch.rs +++ b/src-rust/crates/query/src/skill_prefetch.rs @@ -71,6 +71,12 @@ pub type SharedSkillIndex = Arc>; 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. + let disabled = claurst_core::config::Settings::load_sync() + .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 = { let mut dirs = Vec::new(); @@ -87,7 +93,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 +115,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..cc08bb3 100644 --- a/src-rust/crates/tools/src/skill_tool.rs +++ b/src-rust/crates/tools/src/skill_tool.rs @@ -154,9 +154,17 @@ fn skill_search_dirs(ctx: &ToolContext) -> Vec { } async fn list_skills(dirs: &[PathBuf]) -> ToolResult { - // Start with the bundled skills. + // Skills toggled off in the `/skills` picker are hidden from the model. + let disabled = claurst_core::config::Settings::load_sync() + .map(|s| s.disabled_skills) + .unwrap_or_default(); + + // Start with the bundled skills (minus any the user disabled). let mut lines: Vec = 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 +180,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/app.rs b/src-rust/crates/tui/src/app.rs index 12a38fe..0a237fe 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -1314,6 +1314,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. @@ -1619,6 +1621,40 @@ pub fn accent_for_mode(mode: Option<&str>) -> Color { } } +/// Pick a foreground color that reads clearly on top of `bg`. +/// +/// Returns explicit `Color::Rgb` black or white (never the indexed +/// `Color::Black`/`Color::White`) so terminals don't brighten a *bold* indexed +/// black into low-contrast gray — the cause of the washed-out badge text. The +/// choice is made by comparing WCAG relative-luminance contrast ratios, so it +/// stays correct if the accent palette changes. +pub fn readable_fg_on(bg: Color) -> Color { + let (r, g, b) = match bg { + Color::Rgb(r, g, b) => (r, g, b), + // Non-RGB backgrounds (themes/indexed): default to black, which reads + // on the light/mid accent tones used here. + _ => return Color::Rgb(0, 0, 0), + }; + + fn channel_lum(c: u8) -> f32 { + let c = c as f32 / 255.0; + if c <= 0.03928 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + + let lum = 0.2126 * channel_lum(r) + 0.7152 * channel_lum(g) + 0.0722 * channel_lum(b); + let black_contrast = (lum + 0.05) / 0.05; + let white_contrast = 1.05 / (lum + 0.05); + if black_contrast >= white_contrast { + Color::Rgb(0, 0, 0) + } else { + Color::Rgb(255, 255, 255) + } +} + fn format_elapsed_ms(ms: u128) -> String { let total_secs = ((ms + 500) / 1000) as u64; // round to nearest second if total_secs < 60 { @@ -1764,6 +1800,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(), @@ -2458,6 +2495,11 @@ impl App { ("effort", "fast", _) => return self.intercept_slash_command("fast"), _ => {} } + // `/skills` opens the interactive picker; `/skills ` 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; } @@ -2925,6 +2967,32 @@ 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()); + } + let _ = settings.save_sync(); + self.status_message = Some(format!( + "Skill '{}' {}.", + name, + if enabled { "enabled" } else { "disabled" } + )); + } + } + fn refresh_global_search(&mut self) { let root = self.project_root(); self.global_search.run_search(&root); @@ -3662,6 +3730,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 { diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index 0f047d4..d536a8e 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -93,6 +93,8 @@ pub mod dialogs; pub mod diff_viewer; /// Effort-level picker dialog (/effort). pub mod effort_picker; +/// Interactive skills picker dialog (/skills). +pub mod skills_picker; /// MCP elicitation dialog (form-based user input requested by MCP servers). pub mod elicitation_dialog; /// Export format picker dialog (/export). diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 206c965..9778e06 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -3,7 +3,9 @@ use std::cell::RefCell; use crate::agents_view::render_agents_menu; -use crate::app::{App, ContextMenuKind, SystemAnnotation, SystemMessageStyle, ToolStatus}; +use crate::app::{ + readable_fg_on, App, ContextMenuKind, SystemAnnotation, SystemMessageStyle, ToolStatus, +}; use crate::ask_user_dialog::render_ask_user_dialog; use crate::bypass_permissions_dialog::render_bypass_permissions_dialog; use crate::context_viz::render_context_viz; @@ -682,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); @@ -2153,7 +2160,7 @@ fn render_input(frame: &mut Frame, app: &App, area: Rect, focused: bool) { Span::styled( format!(" {} ", agent_mode.to_uppercase()), Style::default() - .fg(Color::Black) + .fg(readable_fg_on(pink)) .bg(pink) .add_modifier(Modifier::BOLD), ), @@ -2181,7 +2188,7 @@ fn render_input(frame: &mut Frame, app: &App, area: Rect, focused: bool) { Span::styled( " /connect ", Style::default() - .fg(Color::Black) + .fg(readable_fg_on(pink)) .bg(pink) .add_modifier(Modifier::BOLD), ), 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..48c01d8 --- /dev/null +++ b/src-rust/crates/tui/src/skills_picker.rs @@ -0,0 +1,372 @@ +// 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, +} + +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, 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 { + 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 { + let mut rows: Vec = Vec::new(); + let mut seen: std::collections::HashSet = 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 = 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 = 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 { + 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::(); + + for needle in [ + "Skills", + "Search skills", + "brainstorming", + "on", + "off", + "user", + "builtin", + "tok", + "toggle", + ] { + assert!(rendered.contains(needle), "render missing {needle:?}"); + } + } +} From b84be3d295b36bcc330532ab044d9e1fe49b7d0a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:48:13 -0500 Subject: [PATCH 02/10] fix(tui): skills picker input routing, log noise, and scope labels Found while verifying the /skills picker in the running app: - Fast-typed/pasted filter input leaked into the prompt because prompt_is_accepting_text() didn't treat the picker as owning input; the paste-burst detector captured it. Guard on skills_picker.visible (and register it in any_modal_open so the background prompt cursor is suppressed while the picker is open). - discover_skills logged a tracing::warn per duplicate skill; with multi-environment inheritance, the same skill in ~/.claude and ~/.agents is expected, and the warnings flooded the TUI. Downgrade to debug. - Inherited home-level skills were labeled "project" instead of "user" when the checkout lives under $HOME (the project walk-up reached the home dir first). Stop the walk-up at $HOME and attribute home-level .coven-code/.agents/.claude skill dirs to the User scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-rust/crates/core/src/skill_discovery.rs | 22 +++++++++++++++++---- src-rust/crates/tui/src/app.rs | 2 ++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src-rust/crates/core/src/skill_discovery.rs b/src-rust/crates/core/src/skill_discovery.rs index c2a4dda..774407f 100644 --- a/src-rust/crates/core/src/skill_discovery.rs +++ b/src-rust/crates/core/src/skill_discovery.rs @@ -312,7 +312,7 @@ fn read_and_parse(path: &Path) -> Option { /// 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, @@ -336,10 +336,17 @@ 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 { + if home.as_deref() == Some(dir) { + break; + } add(scan_skill_root( &dir.join(".coven-code").join("skills"), SkillScope::Project, @@ -363,12 +370,17 @@ pub fn discover_skills( } // ---- 2. User skills: home directory ------------------------------------- - if let Some(home) = dirs::home_dir() { + 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, @@ -420,9 +432,11 @@ pub fn discover_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 diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 0a237fe..92c22b4 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -2926,6 +2926,7 @@ 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() } @@ -6417,6 +6418,7 @@ impl App { && !self.history_search_overlay.visible && !self.settings_screen.visible && !self.theme_screen.visible + && !self.skills_picker.visible && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert } From eb7e2d2dcdd0b5d9afcc65c259a664b55d19eba9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:19:35 -0500 Subject: [PATCH 03/10] fix(tui): stop model-picker filter input from leaking to the prompt The /model picker has its own search box but, like the skills picker before it, wasn't listed in prompt_is_accepting_text(), so a fast-typed or pasted filter was captured by the prompt's paste-burst detector instead of the picker. Guard on model_picker.visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-rust/crates/tui/src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 92c22b4..e292f13 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -6419,6 +6419,7 @@ impl App { && !self.settings_screen.visible && !self.theme_screen.visible && !self.skills_picker.visible + && !self.model_picker.visible && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert } From 7ea41078cae392cc7d30548d57a89953f53b041e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:53:07 -0500 Subject: [PATCH 04/10] fix(tui): stop command-palette filter input from leaking to the prompt Same class as the skills/model picker fixes: the Ctrl+K command palette has a search box but wasn't listed in prompt_is_accepting_text(), so a fast-typed or pasted filter was captured by the prompt's paste-burst detector. Guard on command_palette.visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-rust/crates/tui/src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index e292f13..df8c65e 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -6420,6 +6420,7 @@ impl App { && !self.theme_screen.visible && !self.skills_picker.visible && !self.model_picker.visible + && !self.command_palette.visible && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert } From 70899e2e06f2e807a50bd90237077071601482dc Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:42:04 -0500 Subject: [PATCH 05/10] refactor(tui): gate prompt text input on a single blocking-modal predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-maintained list of overlay `.visible` checks in prompt_is_accepting_text() (the parallel list that caused the skills / model / command-palette paste-burst leaks) with one source of truth. Split any_modal_open() into any_blocking_modal_open() (input-capturing overlays) plus the passive notices (overage / voice / memory) that the user can keep typing underneath. prompt_is_accepting_text() now gates on any_blocking_modal_open(), so every current and future input-owning overlay automatically blocks prompt text and paste-burst capture — no parallel list to forget. any_modal_open() (used for rendering) keeps the notices and is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-rust/crates/tui/src/app.rs | 44 ++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index df8c65e..c7fc93d 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"), @@ -167,7 +171,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", } @@ -2887,7 +2893,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 @@ -2903,9 +2918,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 @@ -2930,6 +2942,15 @@ impl App { || 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(); @@ -6412,15 +6433,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.skills_picker.visible - && !self.model_picker.visible - && !self.command_palette.visible + && !self.any_blocking_modal_open() && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert } From 92807635b6ab2c8dca90791edadee640c7c2808c Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:05:45 -0500 Subject: [PATCH 06/10] docs(skills): address review nits on comments and design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - estimate_tokens: drop the inaccurate reference to `token_budget` (the heuristic lives here, not there); describe it as a display-only estimate. - scan_skill_root: the SKILL.md lookup checks `SKILL.md`/`skill.md`, not a case-insensitive stem — fix the comment to match. - spec: SkillScope is `{ Project, User, Plugin }`; bundled skills aren't a discovery scope, they're merged at the picker layer with a `builtin` label. Correct the enum and the roots table. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-23-inherited-skills-picker-design.md | 9 +++++++-- src-rust/crates/core/src/skill_discovery.rs | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) 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 index d3777fa..cf0b33d 100644 --- a/docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md +++ b/docs/superpowers/specs/2026-06-23-inherited-skills-picker-design.md @@ -40,7 +40,9 @@ coven-code today. We also lack the interactive, searchable, toggleable `DiscoveredSkill` gains: -- `scope: SkillScope` — new enum `{ Bundled, Project, User, Plugin }`. +- `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. @@ -54,9 +56,12 @@ higher-priority source keeps the entry): | 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 | -| 4 | Bundled | in-binary `BUNDLED_SKILLS` (merged at the listing layer, not in this fn) | | — | 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 diff --git a/src-rust/crates/core/src/skill_discovery.rs b/src-rust/crates/core/src/skill_discovery.rs index 774407f..bddaec8 100644 --- a/src-rust/crates/core/src/skill_discovery.rs +++ b/src-rust/crates/core/src/skill_discovery.rs @@ -50,8 +50,8 @@ impl SkillScope { } } -/// Rough token estimate for a string, mirroring the `chars / 4` convention -/// used elsewhere in the codebase (see `token_budget`). +/// Rough token estimate for a string using the common `chars / 4` +/// approximation. This is an estimate for display only, not an exact count. pub fn estimate_tokens(text: &str) -> usize { text.chars().count().div_ceil(4) } @@ -258,7 +258,7 @@ fn scan_skill_root(dir: &Path, scope: SkillScope, origin: &str) -> Vec Date: Wed, 24 Jun 2026 15:14:57 -0500 Subject: [PATCH 07/10] =?UTF-8?q?fix(skills):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20async=20settings=20load=20+=20save=20error=20han?= =?UTF-8?q?dling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skill_tool::list_skills and query::prefetch_skills are async but read settings via the blocking Settings::load_sync(); switch to the async Settings::load().await so the Tokio runtime isn't blocked. - toggle_selected_skill ignored save_sync() failures, leaving the on-screen on/off state out of sync with disk. Handle the error: revert the toggle and surface a failure message. (The third comment — a stale "case-insensitive" doc note — was already fixed in 9280763.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src-rust/crates/query/src/skill_prefetch.rs | 6 +++-- src-rust/crates/tools/src/skill_tool.rs | 4 +++- src-rust/crates/tui/src/app.rs | 26 +++++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src-rust/crates/query/src/skill_prefetch.rs b/src-rust/crates/query/src/skill_prefetch.rs index 9c66f52..f35dfb6 100644 --- a/src-rust/crates/query/src/skill_prefetch.rs +++ b/src-rust/crates/query/src/skill_prefetch.rs @@ -72,8 +72,10 @@ 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. - let disabled = claurst_core::config::Settings::load_sync() + // 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(); diff --git a/src-rust/crates/tools/src/skill_tool.rs b/src-rust/crates/tools/src/skill_tool.rs index cc08bb3..5582f21 100644 --- a/src-rust/crates/tools/src/skill_tool.rs +++ b/src-rust/crates/tools/src/skill_tool.rs @@ -155,7 +155,9 @@ fn skill_search_dirs(ctx: &ToolContext) -> Vec { async fn list_skills(dirs: &[PathBuf]) -> ToolResult { // Skills toggled off in the `/skills` picker are hidden from the model. - let disabled = claurst_core::config::Settings::load_sync() + // 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(); diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index c7fc93d..fa32a89 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -158,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" => { @@ -3006,12 +3008,22 @@ impl App { } else { settings.disabled_skills.insert(name.clone()); } - let _ = settings.save_sync(); - self.status_message = Some(format!( - "Skill '{}' {}.", - name, - if enabled { "enabled" } else { "disabled" } - )); + 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)); + } + } } } From 95c08618f7f8b2f86ea0c89049fd669b9de3905e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:28:44 -0500 Subject: [PATCH 08/10] feat(commands): add /learn skill-authoring command + reserve agent/familiar names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /learn ("Hermes, the coven's scribe") injects a guided prompt that distils the script or workflow we just built into a reusable .coven-code/skills//SKILL.md — auto-discovered, invocable as /, model-callable, and toggleable in /skills. Wired into COMMANDS, PROMPT_SLASH_COMMANDS, and slash_command_category; unit tested. Reserve names so they stay the user's own: - val / vale / valentina: blocked for ANY agent. - nova / echo / sage / kitty / cody / charm / astra: blocked for familiars. Enforced at the runtime agent-map builders, the /familiar loader, and the create/edit commands (covers renaming onto a reserved name). Case-insensitive. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-rust/crates/commands/src/lib.rs | 151 +++++++++++++++ .../crates/commands/src/named_commands.rs | 13 ++ src-rust/crates/core/src/coven_shared.rs | 174 ++++++++++++++++-- src-rust/crates/tui/src/agents_view.rs | 12 ++ 4 files changed, 334 insertions(+), 16 deletions(-) diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 1e3cd9f..4741039 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; @@ -3201,6 +3202,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 +9820,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), @@ -10116,6 +10230,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()); 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/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; From 207a1179a46cc2e70e8917b7183b0d49867a064e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:49:10 -0500 Subject: [PATCH 09/10] Rename CLI to `coven`, add /splash toggle Rename user-facing CLI from `coven-code` to `coven` across docs and UI copy, prefer @opencoven/coven npm package, and update install/upgrade/uninstall/build instructions. Add a new `/splash` slash command with persistence to settings (show_splash config), expose Config.show_splash accessor, and wire rendering so the welcome/splash panel respects the setting. Add UI/terminal rendering and tests for splash behavior, plus small formatting and whitespace tweaks. Docs: add /splash usage, demo entry, and package/hero copy updates. --- README.md | 6 +- docs/commands.md | 15 ++- docs/index.md | 33 +++--- docs/installation.md | 105 +++++++++++++------ docs/src/content/getting-started.js | 47 ++++----- docs/src/content/installation.js | 49 ++++++--- docs/src/content/introduction.js | 2 +- docs/src/demos.js | 1 + docs/src/hero.js | 2 +- docs/src/main.js | 2 +- docs/src/palette-data.js | 1 + src-rust/crates/commands/src/lib.rs | 103 ++++++++++++++++++ src-rust/crates/core/src/lib.rs | 15 ++- src-rust/crates/core/src/skill_discovery.rs | 6 +- src-rust/crates/tui/src/app.rs | 3 +- src-rust/crates/tui/src/lib.rs | 4 +- src-rust/crates/tui/src/onboarding_dialog.rs | 47 +++++++-- src-rust/crates/tui/src/render.rs | 34 +++++- src-rust/crates/tui/src/skills_picker.rs | 8 +- 19 files changed, 368 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index b7d41d3..20019ae 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`. +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<version>`. 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<version>`. 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 <user>!` 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 430c25f..c846950 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 <harness> "<task>"` 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 @@ -103,14 +136,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. --- @@ -192,14 +223,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 @@ -208,10 +242,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: @@ -250,8 +284,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 @@ -262,7 +296,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. @@ -271,16 +305,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. @@ -290,16 +324,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 @@ -318,7 +361,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() { <h2>1. Install</h2> - <h3>Linux / macOS</h3> - <pre><code data-lang="bash">curl -fsSL https://github.com/OpenCoven/coven-code/releases/latest/download/install.sh | bash</code></pre> - - <h3>Windows (PowerShell)</h3> - <pre><code data-lang="bash">irm https://github.com/OpenCoven/coven-code/releases/latest/download/install.ps1 | iex</code></pre> - - <p>The installer auto-detects your platform/arch, drops <code>coven-code</code> into <code>~/.coven-code/bin/</code>, and adds it to your <code>PATH</code>.</p> - <h3>npm</h3> - <pre><code data-lang="bash">npm i -g coven-code</code></pre> + <pre><code data-lang="bash">npm install -g @opencoven/coven</code></pre> + + <p>This installs the <code>coven</code> CLI. Run <code>coven</code> with no arguments, or <code>coven tui</code> explicitly, for the interactive UI.</p> <h3>From Source</h3> <pre><code data-lang="bash">git clone https://github.com/OpenCoven/coven-code @@ -27,17 +21,17 @@ cargo install --path crates/cli</code></pre> <pre><code data-lang="bash">export ANTHROPIC_API_KEY=sk-ant-...</code></pre> - <p>Or run <code>coven-code /login</code> to authenticate via OAuth (Claude.ai or ChatGPT). Multiple named accounts can coexist; switch with <code>/switch <id></code>.</p> + <p>Or launch <code>coven</code> and run <code>/login</code> to authenticate via OAuth (Claude.ai or ChatGPT). Multiple named accounts can coexist; switch with <code>/switch <id></code>.</p> <h2>3. Run Interactively</h2> - <pre><code data-lang="bash">coven-code</code></pre> + <pre><code data-lang="bash">coven</code></pre> <p>This drops you into the TUI. The first screen is the <a href="#welcome-screen">welcome screen</a>, which surfaces the active model, provider, daemon status, and familiar.</p> - <p>Or send a single prompt and exit:</p> + <p>Or launch a direct harness session:</p> - <pre><code data-lang="bash">coven-code --print "explain the auth module"</code></pre> + <pre><code data-lang="bash">coven run codex "explain the auth module"</code></pre> <h2>Interactive vs Headless</h2> @@ -51,35 +45,35 @@ cargo install --path crates/cli</code></pre> <div class="compare-card-name">interactive</div> <span class="compare-card-tag">day-to-day</span> <div class="compare-card-desc">Full ratatui TUI with streaming, slash commands, permission dialogs, session history. The default when you launch with no args.</div> - <div class="compare-card-cmd">coven-code</div> + <div class="compare-card-cmd">coven</div> </div> <div class="compare-card"> - <div class="compare-card-name">single prompt</div> + <div class="compare-card-name">direct harness</div> <span class="compare-card-tag">one-shot</span> - <div class="compare-card-desc">One pass over a single task, then exit. TUI still renders the run so you can watch tools fire in real time.</div> - <div class="compare-card-cmd">coven-code "task"</div> + <div class="compare-card-desc">Run one named harness against a task from the Coven CLI.</div> + <div class="compare-card-cmd">coven run codex "task"</div> </div> <div class="compare-card"> - <div class="compare-card-name">headless print</div> - <span class="compare-card-tag">scripts · CI</span> - <div class="compare-card-desc">Plain text output to stdout — no TUI, no colour codes, no permission prompts. Use in shell pipelines and CI runners.</div> - <div class="compare-card-cmd">coven-code --print "task"</div> + <div class="compare-card-name">claude harness</div> + <span class="compare-card-tag">alternate</span> + <div class="compare-card-desc">Use Claude Code through the same Coven harness runner.</div> + <div class="compare-card-cmd">coven run claude "task"</div> </div> <div class="compare-card"> - <div class="compare-card-name">json output</div> + <div class="compare-card-name">sessions json</div> <span class="compare-card-tag">machine</span> - <div class="compare-card-desc">Single JSON document with the full run transcript, tool calls, and final result. Parse with jq or feed into downstream tooling.</div> - <div class="compare-card-cmd">coven-code --output-format json "task"</div> + <div class="compare-card-desc">List known Coven sessions as JSON for scripts, dashboards, or local workflow tooling.</div> + <div class="compare-card-cmd">coven sessions --json</div> </div> <div class="compare-card"> <div class="compare-card-name">stream-json</div> <span class="compare-card-tag">real-time</span> <div class="compare-card-desc">Newline-delimited JSON events as they happen — useful for streaming progress into another process or live UI.</div> - <div class="compare-card-cmd">coven-code --output-format stream-json "task"</div> + <div class="compare-card-cmd">coven run codex "task" --stream-json</div> </div> </div> </div> @@ -89,7 +83,8 @@ cargo install --path crates/cli</code></pre> <p>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.</p> - <pre><code data-lang="bash">npm install -g @opencoven/coven</code></pre> + <pre><code data-lang="bash">npm install -g @opencoven/coven +coven daemon start</code></pre> <p>Coven Code is fully standalone without the daemon — install it separately to unlock the Coven ecosystem features.</p> `; 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 ` <h1>Installation</h1> - <p class="lead">A statically-linked Rust binary with no runtime dependencies. Install via the official installer script, npm, or build from source.</p> + <p class="lead">Install the Coven CLI with npm or bun, then run <code>coven</code> or <code>coven tui</code> to open the interactive Coven Code UI.</p> <h2>System Requirements</h2> @@ -20,6 +20,17 @@ export function render() { <h2>Quick Install</h2> + <pre><code data-lang="bash">npm install -g @opencoven/coven +# or +bun install -g @opencoven/coven</code></pre> + + <p>The installed command is <code>coven</code>. Run <code>coven</code> with no arguments, or <code>coven tui</code> explicitly, for the interactive UI. Use <code>coven doctor</code> to inspect local setup, <code>coven daemon start</code> to start the local daemon, and <code>coven run <harness> "<task>"</code> for direct harness sessions.</p> + + <pre><code data-lang="bash">npx @opencoven/coven +bunx @opencoven/coven</code></pre> + + <h2>Standalone Coven Code Binary</h2> + <h3>Linux / macOS</h3> <pre><code data-lang="bash">curl -fsSL https://github.com/OpenCoven/coven-code/releases/latest/download/install.sh | bash</code></pre> @@ -42,7 +53,9 @@ export function render() { </tbody> </table> - <h2>Via npm / bun</h2> + <h2>Coven Code npm Package</h2> + + <p>Prefer <code>@opencoven/coven</code> for the user-facing <code>coven</code> CLI. The lower-level Coven Code package installs the <code>coven-code</code> binary directly.</p> <pre><code data-lang="bash">npm install -g @opencoven/coven-code # or @@ -55,11 +68,11 @@ bunx @opencoven/coven-code</code></pre> <h2>Upgrading</h2> - <pre><code data-lang="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</code></pre> + <pre><code data-lang="bash">npm install -g @opencoven/coven@latest +# or +bun install -g @opencoven/coven@latest</code></pre> - <p>Settings under <code>~/.coven-code/</code> are preserved.</p> + <p>Settings under <code>~/.coven/</code> and <code>~/.coven-code/</code> are preserved.</p> <h2>Manual Install</h2> @@ -78,9 +91,11 @@ coven-code upgrade --force # reinstall the same version</code></pre> <h2>From Source</h2> - <h3>Via Cargo</h3> + <h3>From a Clone</h3> - <pre><code data-lang="bash">cargo install --git https://github.com/OpenCoven/coven-code coven-code-cli</code></pre> + <pre><code data-lang="bash">git clone https://github.com/OpenCoven/coven-code +cd coven-code/src-rust +cargo install --path crates/cli --locked</code></pre> <h3>Clone and Build</h3> @@ -88,10 +103,10 @@ coven-code upgrade --force # reinstall the same version</code></pre> 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</code></pre> +cargo build --release --package claurst</code></pre> <h3>Linux System Dependencies</h3> @@ -106,18 +121,24 @@ sudo pacman -S base-devel pkgconf openssl</code></pre> <h2>Shell Completions</h2> + <p>Coven does not currently ship a dedicated completions subcommand. All flags can be discovered via <code>coven --help</code>. If you want basic tab completion in bash or zsh, use the generic completion helper built into your shell:</p> + <pre><code data-lang="bash"># bash — add to ~/.bashrc -eval "$(coven-code completion bash)" +complete -C coven coven # zsh — add to ~/.zshrc (requires compinit) -eval "$(coven-code completion zsh)"</code></pre> +compdef _gnu_generic coven</code></pre> <h2>Uninstalling</h2> - <pre><code data-lang="bash">rm -rf ~/.coven-code # Linux / macOS + <pre><code data-lang="bash">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</code></pre> +Remove-Item -Recurse -Force $env:USERPROFILE\\.coven, $env:USERPROFILE\\.coven-code</code></pre> <p>See <a href="https://github.com/OpenCoven/coven-code/blob/main/docs/installation.md" target="_blank" rel="noopener">the full installation reference</a> for cross-compiling to Linux aarch64, optional cargo features, and user-local installs without sudo.</p> `; 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() { <p>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.</p> - <pre><code data-lang="bash">$ coven-code "add input validation to the signup form"</code></pre> + <pre><code data-lang="bash">$ coven run codex "add input validation to the signup form"</code></pre> <p>It reads your codebase, implements the change across multiple files, runs your tests, and reports back — without you leaving the terminal.</p> 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) { <span class="hero-star-badge px-1.5 py-px rounded-lg text-[11.5px] font-semibold tabular-nums" ${starCount ? '' : 'style="display:none"'}>${starCount ? formatStars(starCount) : ''}</span> </a> <button id="hero-copy-btn" title="Copy to clipboard" class="hero-install-btn group inline-flex items-center gap-2.5 rounded-[10px] px-5 h-10 cursor-pointer"> - <code class="font-[var(--font-mono)] text-sm font-medium text-accent tracking-[-0.02em] !bg-transparent !border-0 !p-0">npm i -g coven-code</code> + <code class="font-[var(--font-mono)] text-sm font-medium text-accent tracking-[-0.02em] !bg-transparent !border-0 !p-0">npm i -g @opencoven/coven</code> <svg class="hero-copy-icon text-text-dimmer group-hover:text-accent transition-colors" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <svg class="hero-check-icon hidden text-text-secondary" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> </button> 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/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 4741039..297c509 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -251,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; @@ -1115,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] @@ -9834,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), @@ -10277,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/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 75ac33f..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; @@ -994,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, @@ -1307,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() @@ -1788,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 { diff --git a/src-rust/crates/core/src/skill_discovery.rs b/src-rust/crates/core/src/skill_discovery.rs index bddaec8..21f037d 100644 --- a/src-rust/crates/core/src/skill_discovery.rs +++ b/src-rust/crates/core/src/skill_discovery.rs @@ -304,7 +304,6 @@ fn read_and_parse(path: &Path) -> Option<DiscoveredSkill> { } } - // --------------------------------------------------------------------------- // Top-level discovery // --------------------------------------------------------------------------- @@ -559,7 +558,10 @@ mod tests { 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")); + assert_eq!( + skill.when_to_use.as_deref(), + Some("before any creative work") + ); } #[test] diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index fa32a89..052a38a 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -3020,8 +3020,7 @@ impl App { // 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)); + self.status_message = Some(format!("Failed to save skill '{}': {}", name, e)); } } } diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index d536a8e..0c30ee7 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -93,8 +93,6 @@ pub mod dialogs; pub mod diff_viewer; /// Effort-level picker dialog (/effort). pub mod effort_picker; -/// Interactive skills picker dialog (/skills). -pub mod skills_picker; /// MCP elicitation dialog (form-based user input requested by MCP servers). pub mod elicitation_dialog; /// Export format picker dialog (/export). @@ -167,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 9778e06..a242bf8 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -1070,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) @@ -1097,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() @@ -3625,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"); @@ -3640,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 index 48c01d8..299f9d2 100644 --- a/src-rust/crates/tui/src/skills_picker.rs +++ b/src-rust/crates/tui/src/skills_picker.rs @@ -237,7 +237,10 @@ pub fn render_skills_picker(frame: &mut Frame, state: &SkillsPickerState, area: format!(" {} ", cursor), Style::default().fg(COVEN_CODE_ACCENT), ), - Span::styled(format!("{} ", state_glyph), Style::default().fg(state_color)), + 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), @@ -345,7 +348,8 @@ mod tests { 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(); + term.draw(|f| render_skills_picker(f, &s, f.area())) + .unwrap(); let rendered = term .backend() From 7b2be96647e8c15fa55495f91d948838029c5b7e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:59:55 -0500 Subject: [PATCH 10/10] fix(skills): canonical token estimate, empty-name fallback, doc accuracy Addresses the remaining Copilot review nits on skill discovery: - estimate_tokens delegates to the canonical message_utils::estimate_tokens so the heuristic is single-sourced and counts don't drift. - A present-but-empty `name:` frontmatter value now falls back to the file stem instead of becoming an empty HashMap key. - Corrected the parse_skill_file / first_body_line docs to say leading `#` heading markers are stripped (the prior "non-heading" wording was wrong). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src-rust/crates/core/src/skill_discovery.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src-rust/crates/core/src/skill_discovery.rs b/src-rust/crates/core/src/skill_discovery.rs index 21f037d..81bb465 100644 --- a/src-rust/crates/core/src/skill_discovery.rs +++ b/src-rust/crates/core/src/skill_discovery.rs @@ -50,10 +50,12 @@ impl SkillScope { } } -/// Rough token estimate for a string using the common `chars / 4` -/// approximation. This is an estimate for display only, not an exact count. +/// 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 { - text.chars().count().div_ceil(4) + crate::message_utils::estimate_tokens(text) as usize } /// A discovered skill loaded from a markdown file. @@ -97,8 +99,9 @@ impl DiscoveredSkill { /// Parse a skill markdown file. /// /// Expects optional YAML frontmatter delimited by `---`. When `description` -/// is absent (e.g. Codex prompts), it falls back to the first non-empty, -/// non-heading body line. Returns `None` when the file is empty after trimming. +/// 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. @@ -124,7 +127,9 @@ pub fn parse_skill_file(content: &str, path: &Path) -> Option<DiscoveredSkill> { (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") @@ -220,7 +225,8 @@ fn parse_frontmatter(frontmatter: &str) -> (Option<String>, Option<String>, Opti (name, description, when_to_use) } -/// First non-empty, non-heading line of a body, truncated to 200 chars. +/// 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();