From 1b9cf072c2eb150e59865269d74f72ab969effe7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 1 May 2026 01:38:04 -0500 Subject: [PATCH] feat(skills): expose installed skills to the model + zh-CN README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a model-visible skills block to the system prompt (progressive disclosure: lists name/description/path, never inlines SKILL.md bodies) with a 12k-char prompt budget and a 512-char per-description cap. EngineConfig gains skills_dir, threaded through the three construction sites (TUI app, exec agent, runtime thread manager). README skills section is rewritten to document the discovery order, the SKILL.md frontmatter contract, and the install/update/uninstall/ trust commands. Adds Simplified Chinese README cross-link and full README.zh-CN.md translation (DeepSeek went viral in CN -- discoverability matters). Tests cover happy path, empty/missing dir → None, oversize description truncation with U+2026 marker, internal-whitespace collapse, and the overflow-budget omission notice. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 38 +++- README.zh-CN.md | 289 ++++++++++++++++++++++++++++++ crates/tui/src/core/engine.rs | 18 +- crates/tui/src/main.rs | 1 + crates/tui/src/prompts.rs | 15 ++ crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/skills/mod.rs | 196 ++++++++++++++++++++ crates/tui/src/tui/ui.rs | 1 + 8 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index 063d733e..332c632b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > **A terminal-native coding agent for [DeepSeek V4](https://platform.deepseek.com) models — with 1M-token context, thinking-mode reasoning, and full tool-use.** +[简体中文 README](README.zh-CN.md) + ```bash npm i -g deepseek-tui ``` @@ -332,8 +334,37 @@ or via the `LC_ALL`/`LANG` environment variables. See [docs/CONFIGURATION.md](do ## Publishing your own skill -DeepSeek-TUI can install community skills directly from a GitHub repo, with no -backend service in the loop: +DeepSeek-TUI discovers skills from the active skills directory. Workspace-local +`.agents/skills` wins when present, then `./skills`, then the configured global +directory (`~/.deepseek/skills` by default). Each skill is a directory with a +`SKILL.md` file: + +```text +~/.deepseek/skills/my-skill/ +└── SKILL.md +``` + +`SKILL.md` must start with YAML frontmatter: + +```markdown +--- +name: my-skill +description: Use this when DeepSeek should follow my custom workflow. +--- + +# My Skill + +Instructions for the agent go here. +``` + +Run `/skills` to list discovered skills, `/skill ` to activate one for +the next message, or `/skill new` to use the bundled skill-creator helper. +Installed skills are also listed in the model-visible session context so the +agent can choose relevant skills when the user names them or when the task +matches their descriptions. + +DeepSeek-TUI can also install community skills directly from a GitHub repo, +with no backend service in the loop: 1. Create a public GitHub repo with a `SKILL.md` at the root containing the usual `---` frontmatter (`name`, `description`). @@ -346,6 +377,9 @@ backend service in the loop: placed under `~/.deepseek/skills//`. 5. Submit a PR to the curated `index.json` (default registry) to make the skill installable by name (`/skill install `) instead of the GitHub spec. +6. Use `/skill update `, `/skill uninstall `, or + `/skill trust ` for installed community skills. Trust is only needed + when you want scripts bundled with a skill to be eligible for execution. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..9eadfc6d --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,289 @@ +# DeepSeek TUI + +> **面向 [DeepSeek V4](https://platform.deepseek.com) 模型的终端原生编程智能体,支持 100 万 token 上下文、思考模式推理流和完整工具调用。** + +[English README](README.md) + +```bash +npm i -g deepseek-tui +``` + +[![CI](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/deepseek-tui)](https://www.npmjs.com/package/deepseek-tui) + +![DeepSeek TUI screenshot](assets/screenshot.png) + +--- + +## 这是什么? + +DeepSeek TUI 是一个完全运行在终端里的编程智能体。它可以让 DeepSeek 前沿模型直接访问你的工作区:读取和编辑文件、运行 shell 命令、搜索和浏览网页、管理 git、调度子智能体,并通过快速的键盘驱动 TUI 完成多步开发任务。 + +它面向 **DeepSeek V4**(`deepseek-v4-pro` / `deepseek-v4-flash`)构建,默认支持 100 万 token 上下文窗口和原生思考模式流式输出。模型推理、工具调用和最终回答会在终端里实时呈现。 + +### 主要功能 + +- **原生 RLM**(`rlm_query` 工具):用现有 DeepSeek 客户端并行调度 1 到 16 个低成本 `deepseek-v4-flash` 子任务,用于批量分析、任务拆解或并行推理。 +- **思考模式流式输出**:实时显示 DeepSeek 的推理过程。 +- **完整工具集**:文件操作、shell 执行、git、网页搜索/浏览、apply-patch、子智能体、MCP 服务器。 +- **100 万 token 上下文**:上下文接近上限时自动进行智能压缩。 +- **三种交互模式**:Plan(只读探索)、Agent(默认交互并带审批)、YOLO(可信工作区内自动批准工具)。 +- **推理强度档位**:用 `Shift+Tab` 在 `off -> high -> max` 之间切换。 +- **会话保存和恢复**:适合长任务的断点续作。 +- **工作区回滚**:通过 side-git 记录每轮前后快照,支持 `/restore` 和 `revert_turn`,不修改项目自己的 `.git`。 +- **HTTP/SSE 运行时 API**:`deepseek serve --http` 可用于无界面智能体流程。 +- **MCP 协议支持**:连接 Model Context Protocol 服务器扩展工具,见 [docs/MCP.md](docs/MCP.md)。 +- **实时成本跟踪**:按轮次和会话统计 token 用量与成本估算。 +- **深色主题**:DeepSeek 蓝色系终端界面。 + +--- + +## 快速开始 + +```bash +npm install -g deepseek-tui +deepseek +``` + +首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。也可以提前配置: + +```bash +# 通过 CLI 保存 +deepseek login --api-key "YOUR_DEEPSEEK_API_KEY" + +# 或通过环境变量 +export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY" +deepseek +``` + +### 中国大陆 / 镜像友好安装 + +如果在中国大陆访问 GitHub 或 npm 下载较慢,可以通过 Cargo 注册表镜像安装 Rust crate: + +```toml +# ~/.cargo/config.toml +[source.crates-io] +replace-with = "tuna" + +[source.tuna] +registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/" +``` + +然后从主包安装已发布的两个二进制文件: + +```bash +cargo install deepseek-tui --locked +deepseek --version +deepseek doctor --json +``` + +从 `v0.8.1` 开始,`deepseek-tui` 这个主 Cargo 包会同时安装: + +- `deepseek`:推荐使用的调度器入口。 +- `deepseek-tui`:交互式 TUI 伴随二进制。 + +也可以直接从 [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) 下载预编译二进制。如果你有镜像后的 release 资产目录,也可以配合 `DEEPSEEK_TUI_RELEASE_BASE_URL` 使用 TUNA、rsproxy、腾讯云 COS 或阿里云 OSS 等镜像。 + +### 从源码安装 + +```bash +git clone https://github.com/Hmbown/DeepSeek-TUI.git +cd DeepSeek-TUI +cargo install --path crates/tui --locked # 需要 Rust 1.85+ +deepseek --version +``` + +--- + +## 其他模型提供方 + +### NVIDIA NIM + +```bash +deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" +deepseek --provider nvidia-nim + +# 或仅对当前进程生效: +DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek +``` + +### Fireworks 和自托管 SGLang + +```bash +deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" +deepseek --provider fireworks --model deepseek-v4-pro + +# SGLang 通常是自托管;localhost 部署可以不配置鉴权。 +SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash +``` + +--- + +## 使用方式 + +```bash +deepseek # 交互式 TUI +deepseek "explain this function" # 一次性提示 +deepseek --model deepseek-v4-flash "summarize" # 指定模型 +deepseek --yolo # YOLO 模式,自动批准工具 +deepseek login --api-key "..." # 保存 API key +deepseek doctor # 检查配置和连接 +deepseek doctor --json # 机器可读诊断 +deepseek setup --status # 只读安装状态检查 +deepseek setup --tools --plugins # 创建本地工具和插件目录 +deepseek models # 列出可用 API 模型 +deepseek sessions # 列出已保存会话 +deepseek resume --last # 恢复最近会话 +deepseek serve --http # HTTP/SSE API 服务 +deepseek mcp list # 列出已配置 MCP 服务器 +deepseek mcp validate # 校验 MCP 配置和连接 +deepseek mcp-server # 启动 dispatcher MCP stdio 服务器 +``` + +### 常用快捷键 + +| 按键 | 功能 | +|---|---| +| `Tab` | 补全 `/` 或 `@`;运行中则把草稿排队为后续消息;否则切换模式 | +| `Shift+Tab` | 切换推理强度:off -> high -> max | +| `F1` | 帮助 | +| `Esc` | 返回 / 关闭 | +| `Ctrl+K` | 命令面板 | +| `Ctrl+R` | 恢复旧会话 | +| `Alt+R` | 搜索提示历史和恢复草稿 | +| `@path` | 在输入框中附加文件或目录上下文 | +| `Alt+↑` | 编辑最后一条排队消息 | +| `/attach ` | 附加图片或视频路径引用 | + +--- + +## 模式 + +| 模式 | 行为 | +|---|---| +| **Plan** | 只读调查;模型先探索并提出拆解计划,再进行更改 | +| **Agent** | 默认交互模式;多步工具调用带审批门禁 | +| **YOLO** | 在可信工作区自动批准工具;仍会保留计划和清单以便追踪 | + +--- + +## 配置 + +主配置文件是 `~/.deepseek/config.toml`。完整选项见 [config.example.toml](config.example.toml) 和 [docs/CONFIGURATION.md](docs/CONFIGURATION.md)。 + +常用环境变量: + +| 变量 | 用途 | +|---|---| +| `DEEPSEEK_API_KEY` | DeepSeek API key | +| `DEEPSEEK_BASE_URL` | API base URL | +| `DEEPSEEK_MODEL` | 默认模型 | +| `DEEPSEEK_PROVIDER` | 提供方:`deepseek`、`nvidia-nim`、`fireworks` 或 `sglang` | +| `DEEPSEEK_PROFILE` | 配置 profile 名称 | +| `NVIDIA_API_KEY` | NVIDIA NIM API key | +| `FIREWORKS_API_KEY` | Fireworks AI API key | +| `SGLANG_BASE_URL` | 自托管 SGLang 端点 | +| `SGLANG_API_KEY` | 可选 SGLang bearer token | + +快速诊断: + +```bash +deepseek setup --status +deepseek doctor --json +``` + +UI 语言与模型输出语言相互独立。可以在 `settings.toml` 里设置 `locale`,也可以通过 `LC_ALL` / `LANG` 环境变量自动选择。支持 `en`、`zh-Hans`、`ja`、`pt-BR` 等界面语言。 + +DeepSeek 上下文缓存是自动的;当 API 返回 cache hit/miss token 字段时,TUI 会把它们纳入用量和成本统计。 + +--- + +## 模型和价格 + +DeepSeek TUI 默认面向带 100 万 token 上下文窗口的 **DeepSeek V4** 模型。 + +| 模型 | 上下文 | 输入(缓存命中) | 输入(缓存未命中) | 输出 | +|---|---|---|---|---| +| `deepseek-v4-pro` | 1M | $0.003625 / 1M* | $0.435 / 1M* | $0.87 / 1M* | +| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M | + +旧别名 `deepseek-chat` 和 `deepseek-reasoner` 会自动映射到 `deepseek-v4-flash`。 + +**NVIDIA NIM** 托管变体(`deepseek-ai/deepseek-v4-pro`、`deepseek-ai/deepseek-v4-flash`)使用你的 NVIDIA 账号条款,不走 DeepSeek 平台计费。 + +*DeepSeek 标注的 Pro 价格是限时 75% 折扣,有效期到 2026-05-05 15:59 UTC;该时间之后 TUI 成本估算会回退到 Pro 基础价格。* + +--- + +## 文档 + +| 文档 | 主题 | +|---|---| +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | 代码库内部结构 | +| [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 | +| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 | +| [MCP.md](docs/MCP.md) | Model Context Protocol 集成 | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务 | +| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | 发布流程 | +| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | 运维和恢复 | + +完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。 + +--- + +## 创建和安装技能 + +DeepSeek-TUI 会从当前技能目录发现技能。优先级是:工作区 +`.agents/skills`、工作区 `./skills`、全局目录(默认 +`~/.deepseek/skills`)。每个技能都是一个包含 `SKILL.md` 的目录: + +```text +~/.deepseek/skills/my-skill/ +└── SKILL.md +``` + +`SKILL.md` 需要以 YAML frontmatter 开头: + +```markdown +--- +name: my-skill +description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技能。 +--- + +# My Skill + +这里写给智能体的指令。 +``` + +常用命令: + +```bash +/skills +/skill my-skill +/skill new +/skill install github:/ +/skill update my-skill +/skill uninstall my-skill +/skill trust my-skill +``` + +`/skills` 列出已发现技能,`/skill ` 会把技能应用到下一条消息, +`/skill new` 会调用内置的 skill-creator 辅助创建新技能。已安装技能也会 +进入模型可见的会话上下文;当用户点名某个技能,或任务明显匹配技能描述时, +智能体可以主动读取对应的 `SKILL.md` 并使用它。 + +社区技能可以直接从 GitHub 安装。安装过程受 `[network]` 策略约束,并会校验 +压缩包大小、路径穿越和符号链接。`/skill trust ` 只在你希望技能内置脚本 +可被执行时才需要。 + +--- + +## 贡献 + +欢迎提交 pull request。请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +*本项目与 DeepSeek Inc. 无隶属关系。* + +## 许可证 + +[MIT](LICENSE) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 126a1f1e..99a12e30 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -83,6 +83,8 @@ pub struct EngineConfig { pub notes_path: PathBuf, /// Path to the MCP configuration file. pub mcp_config_path: PathBuf, + /// Directory containing discoverable skills. + pub skills_dir: PathBuf, /// Maximum number of assistant steps before stopping. pub max_steps: u32, /// Maximum number of concurrently active subagents. @@ -134,6 +136,7 @@ impl Default for EngineConfig { trust_mode: false, notes_path: PathBuf::from("notes.txt"), mcp_config_path: PathBuf::from("mcp.json"), + skills_dir: crate::skills::default_skills_dir(), max_steps: 100, max_subagents: DEFAULT_MAX_SUBAGENTS, features: Features::with_defaults(), @@ -1270,8 +1273,12 @@ impl Engine { // Set up system prompt with project context (default to agent mode) let working_set_summary = session.working_set.summary_block(&config.workspace); - let system_prompt = - prompts::system_prompt_for_mode_with_context(AppMode::Agent, &config.workspace, None); + let system_prompt = prompts::system_prompt_for_mode_with_context_and_skills( + AppMode::Agent, + &config.workspace, + None, + Some(&config.skills_dir), + ); session.system_prompt = append_working_set_summary(Some(system_prompt), working_set_summary.as_deref()); @@ -2759,7 +2766,12 @@ impl Engine { .session .working_set .summary_block(&self.config.workspace); - let base = prompts::system_prompt_for_mode_with_context(mode, &self.config.workspace, None); + let base = prompts::system_prompt_for_mode_with_context_and_skills( + mode, + &self.config.workspace, + None, + Some(&self.config.skills_dir), + ); let stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); self.session.system_prompt = diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 137ec9f2..de6544fa 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3008,6 +3008,7 @@ async fn run_exec_agent( trust_mode, notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), + skills_dir: config.skills_dir(), max_steps: 100, max_subagents, features: config.features(), diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 29c56200..0ca2f2cc 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -172,6 +172,16 @@ pub fn system_prompt_for_mode_with_context( mode: AppMode, workspace: &Path, working_set_summary: Option<&str>, +) -> SystemPrompt { + system_prompt_for_mode_with_context_and_skills(mode, workspace, working_set_summary, None) +} + +/// Get the system prompt for a specific mode with project and skills context. +pub fn system_prompt_for_mode_with_context_and_skills( + mode: AppMode, + workspace: &Path, + working_set_summary: Option<&str>, + skills_dir: Option<&Path>, ) -> SystemPrompt { let mode_prompt = compose_mode_prompt(mode); @@ -197,6 +207,11 @@ pub fn system_prompt_for_mode_with_context( full_prompt = format!("{full_prompt}\n\n{summary}"); } + if let Some(skills_block) = skills_dir.and_then(crate::skills::render_available_skills_context) + { + full_prompt = format!("{full_prompt}\n\n{skills_block}"); + } + if let Some(handoff_block) = load_handoff_block(workspace) { full_prompt = format!("{full_prompt}\n\n{handoff_block}"); } diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 57528da2..69538689 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1537,6 +1537,7 @@ impl RuntimeThreadManager { trust_mode: thread.trust_mode, notes_path: self.config.notes_path(), mcp_config_path: self.config.mcp_config_path(), + skills_dir: self.config.skills_dir(), max_steps: 100, max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS), features: self.config.features(), diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 88207e82..9af9b6f4 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -22,6 +22,9 @@ use std::collections::HashMap; use crate::logging; +const MAX_SKILL_DESCRIPTION_CHARS: usize = 512; +const MAX_AVAILABLE_SKILLS_CHARS: usize = 12_000; + // === Defaults === #[allow(dead_code)] @@ -165,6 +168,96 @@ impl SkillRegistry { } } +/// Render a compact model-visible skills block. +/// +/// The full `SKILL.md` body is intentionally not included here. This mirrors +/// Codex's progressive-disclosure contract: the model sees skill names, +/// descriptions, and paths up front, then opens the specific `SKILL.md` only +/// when a skill is relevant. +#[must_use] +pub fn render_available_skills_context(skills_dir: &Path) -> Option { + let registry = SkillRegistry::discover(skills_dir); + if registry.is_empty() { + return None; + } + + let mut skills = registry.list().to_vec(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut out = String::new(); + out.push_str("## Skills\n"); + out.push_str( + "A skill is a set of local instructions stored in a `SKILL.md` file. \ +Below is the list of skills available in this session. Each entry includes a \ +name, description, and file path so you can open the source for full \ +instructions when using a specific skill.\n\n", + ); + out.push_str("### Available skills\n"); + + let mut omitted = 0usize; + for skill in skills { + let path = skills_dir.join(&skill.name).join("SKILL.md"); + let description = truncate_for_prompt(&skill.description, MAX_SKILL_DESCRIPTION_CHARS); + let line = if description.is_empty() { + format!("- {}: (file: {})\n", skill.name, path.display()) + } else { + format!( + "- {}: {} (file: {})\n", + skill.name, + description, + path.display() + ) + }; + + if out.chars().count() + line.chars().count() > MAX_AVAILABLE_SKILLS_CHARS { + omitted += 1; + } else { + out.push_str(&line); + } + } + + if omitted > 0 { + out.push_str(&format!( + "- ... {omitted} additional skills omitted from this prompt budget.\n" + )); + } + + if !registry.warnings().is_empty() { + out.push_str("\n### Skill load warnings\n"); + for warning in registry.warnings().iter().take(8) { + out.push_str("- "); + out.push_str(&truncate_for_prompt(warning, MAX_SKILL_DESCRIPTION_CHARS)); + out.push('\n'); + } + } + + out.push_str( + "\n### How to use skills\n\ +- Discovery: The list above is the skills available in this session. Skill bodies live on disk at the listed paths.\n\ +- Trigger rules: If the user names a skill (with `$SkillName`, `/skill `, or plain text) OR the task clearly matches a skill description above, use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n\ +- Missing/blocked: If a named skill is missing or its `SKILL.md` cannot be read, say so briefly and continue with the best fallback.\n\ +- Progressive disclosure: After deciding to use a skill, read only that skill's `SKILL.md`. When it references relative paths such as `scripts/foo.py`, resolve them relative to the skill directory.\n\ +- Context hygiene: Load only the specific referenced files needed for the task. Avoid bulk-loading unrelated skill resources.\n\ +- Safety: Do not execute scripts from a community skill unless the user explicitly asks or the skill has been trusted for script use.\n", + ); + + Some(out) +} + +fn truncate_for_prompt(value: &str, max_chars: usize) -> String { + let single_line = value.split_whitespace().collect::>().join(" "); + if single_line.chars().count() <= max_chars { + return single_line; + } + + let mut truncated = single_line + .chars() + .take(max_chars.saturating_sub(1)) + .collect::(); + truncated.push('…'); + truncated +} + // === CLI Helpers === #[allow(dead_code)] // CLI utility for future use @@ -202,3 +295,106 @@ pub fn show(skills_dir: &Path, name: &str) -> Result<()> { println!("{contents}"); Ok(()) } + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) { + let skill_dir = tmpdir.path().join("skills").join(skill_name); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap(); + } + + #[test] + fn render_available_skills_context_lists_paths_and_usage() { + let tmpdir = TempDir::new().unwrap(); + create_skill_dir( + &tmpdir, + "test-skill", + "---\nname: test-skill\ndescription: A test skill\n---\nDo something special", + ); + + let rendered = + crate::skills::render_available_skills_context(&tmpdir.path().join("skills")) + .expect("skill context"); + + assert!(rendered.contains("## Skills")); + assert!(rendered.contains("- test-skill: A test skill")); + assert!(rendered.contains("test-skill/SKILL.md")); + assert!(rendered.contains("### How to use skills")); + } + + #[test] + fn render_available_skills_context_returns_none_when_empty() { + let tmpdir = TempDir::new().unwrap(); + let empty = tmpdir.path().join("skills"); + std::fs::create_dir_all(&empty).unwrap(); + assert!(crate::skills::render_available_skills_context(&empty).is_none()); + + let missing = tmpdir.path().join("does-not-exist"); + assert!(crate::skills::render_available_skills_context(&missing).is_none()); + } + + #[test] + fn render_available_skills_context_truncates_long_descriptions() { + let tmpdir = TempDir::new().unwrap(); + let long_desc = "x".repeat(2_000); + let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody"); + create_skill_dir(&tmpdir, "bigdesc", &body); + + let rendered = + crate::skills::render_available_skills_context(&tmpdir.path().join("skills")) + .expect("skill context"); + + let max = super::MAX_SKILL_DESCRIPTION_CHARS; + assert!(rendered.contains('…'), "expected truncation marker"); + assert!( + !rendered.contains(&"x".repeat(max + 1)), + "untruncated long run should not appear" + ); + } + + #[test] + fn render_available_skills_context_collapses_internal_whitespace() { + let tmpdir = TempDir::new().unwrap(); + create_skill_dir( + &tmpdir, + "spaced-skill", + "---\nname: spaced-skill\ndescription: alpha \t beta gamma\n---\nbody", + ); + + let rendered = + crate::skills::render_available_skills_context(&tmpdir.path().join("skills")) + .expect("skill context"); + + let line = rendered + .lines() + .find(|l| l.starts_with("- spaced-skill:")) + .expect("skill line"); + assert!(line.contains("alpha beta gamma"), "got: {line:?}"); + } + + #[test] + fn render_available_skills_context_omits_overflowing_skills() { + let tmpdir = TempDir::new().unwrap(); + let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20); + for i in 0..200 { + let body = format!("---\nname: skill-{i:03}\ndescription: {big_desc}\n---\nbody"); + create_skill_dir(&tmpdir, &format!("skill-{i:03}"), &body); + } + + let rendered = + crate::skills::render_available_skills_context(&tmpdir.path().join("skills")) + .expect("skill context"); + + assert!( + rendered.contains("additional skills omitted from this prompt budget"), + "expected overflow notice" + ); + assert!( + rendered.chars().count() < super::MAX_AVAILABLE_SKILLS_CHARS + 4_000, + "rendered length should stay near the budget" + ); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f81a2efc..162ffe50 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -355,6 +355,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { trust_mode: app.trust_mode, notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), + skills_dir: app.skills_dir.clone(), // Effectively unlimited. V4 has a 1M context window and the user // wants the model running until it's actually done. The previous cap // of 100 hit the ceiling on long multi-step plans (wide refactors,