diff --git a/docs/superpowers/plans/2026-08-18-skill-dormancy-design.md b/docs/superpowers/plans/2026-08-18-skill-dormancy-design.md new file mode 100644 index 00000000..7693592b --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-skill-dormancy-design.md @@ -0,0 +1,190 @@ +# Skill 休眠(Dormancy)管理 — 设计文档 + +> 2026-08-18 | 基于 brainstorming 需求澄清结论 +> +> 一句话:技能长期未用后,从系统提示词的自动候选列表中隐藏(休眠),但手动调用/path 匹配/显式提及全部保留,调用后自动唤醒。 + +--- + +## 1. 背景与问题 + +ACECode 的技能库随使用增长,每个技能的名称和描述都会注入系统提示词的 `` 列表。很多技能长期未用,它们不删除也不禁用,只是"暂时用不上"——却在列表中占着上下文,干扰模型注意力。 + +**目标**:引入一个低风险、非破坏性的"休眠"机制,让闲置技能不再出现在自动推荐列表里,但保留所有主动使用路径,被调用后自动恢复。 + +--- + +## 2. 三个状态 + +| 状态 | 进入自动列表? | 模型/用户能否使用? | 说明 | +|------|:---:|:---:|------| +| `active`(活跃) | ✅ 是 | ✅ 完全可用 | 正常状态 | +| `dormant`(休眠) | ❌ 否 | ✅ 可用——手动 `/skill`、显式提及名、path 匹配全部正常 | 从列表里"藏起来",不阻碍任何真实调用 | +| `disabled`(禁用) | ❌ 否 | ❌ 不可用 | 现有功能,彻底关闭 | + +**核心区别**:休眠只做"眼不见"(省 context),不做"手不能"。被调用后自动恢复活跃。 + +--- + +## 3. 用户感受 + +### 日常使用 +- 正常使用技能,系统自动记录"最后使用时间" +- 超过 30 天(可配置)未用的技能,下次新会话时不再出现在自动候选列表里 +- 手动 `/技能名` 调用照常执行,同时自动恢复活跃,回到列表 +- path 匹配激活、显式提及名全部保留生效 + +### 查看和管理 +- `/skills` 面板或 Web 设置页展示每个技能的:**使用次数、最后使用时间、当前状态(活跃/休眠)** +- **驻留(pin)**:把重要技能标记为"驻留",永不休眠——即使半年不用也在列表里 +- **解除驻留**:恢复自动判定 + +### 配置 +- `skills.idleDays`(默认 30):超过此天数未用视为休眠 +- 设为 0:关闭休眠功能,所有技能永远活跃 + +--- + +## 4. 架构 + +``` +触发路径(inject_explicit_skill_instructions / path 激活 / 模型自动) + └─ injection 成功后 → record_skill_usage(name) + + ┌─────────────────────────────────────────────┐ + │ skill_usage_store (新模块 src/skills/) │ + │ ~/.acecode/.skill_usage_state.json │ + │ { version:1, skills{ name: {lastUsedAt, │ + │ useCount, pinned} } } │ + │ 进程内 mutex + 原子写(临时文件 + rename) │ + └──────────────┬──────────────────────────────┘ + │ + ┌────────────────┼───────────────────┐ + ▼ ▼ ▼ +自动列表过滤 TUI 展示 Web 展示 +(构建时实时判定) /skills 面板 settings 页 +active→注入 次数/最后使用/状态 次数/状态/pin +dormant→跳过 + pin 操作 + 唤醒操作 +``` + +--- + +## 5. 数据模型 + +```jsonc +// ~/.acecode/.skill_usage_state.json +{ + "version": 1, + "skills": { + "pdf": { "lastUsedAt": "2026-08-01T10:00:00Z", "useCount": 12, "pinned": false }, + "xlsx": { "lastUsedAt": "2026-05-20T09:00:00Z", "useCount": 3, "pinned": false } + } +} +``` + +- `lastUsedAt`:最近一次注入时间(ISO8601),首次使用自动初始化 +- `useCount`:累计注入次数 +- `pinned`:是否驻留(永不休眠) +- **dormant 不落盘为持久标记**——读取时实时判定:`now - lastUsedAt > idleDays && !pinned` → 视为休眠 + +### 配置项 + +**`skills.idleDays`**(新,默认 30): +- 整数,>=0 +- 0 = 关闭休眠功能 +- 进现有 config schema + TUI/Web 设置项 + +--- + +## 6. 记录链路 + +### 触发点 + +所有 skill 注入路径收敛到 `record_skill_usage(name)`: + +| 触发点 | 文件 | 说明 | +|--------|------|------| +| 显式注入 | `agent_loop.cpp`:`inject_explicit_skill_instructions` | 用户 `/skill` 或模型提及名 | +| path 激活 | skill activation 匹配处 | 工具触碰匹配路径,自动激活 | +| 模型自动 | 主循环注入路径 | 模型从列表中选择调用 | + +### record_skill_usage 行为 + +1. 进程内 mutex 锁住 store +2. 读状态文件(带大小上限,防坏文件) +3. 记录存在 → `useCount++`、`lastUsedAt = now`;不存在 → 新建(`useCount = 1`) +4. 原子写(临时文件 + rename,0600 权限) +5. 失败不抛异常——best-effort,不阻断注入主流程 + +**计数口径**:注入即计数。同会话重复注入每次都计。只计成功注入(失败不注入→不计)。 + +--- + +## 7. 休眠过滤 + +### 判定公式 + +``` +is_dormant(skill) = !pinned && (now - lastUsedAt > idleDays) +``` + +### 过滤位置 + +`build_skills_index_context_prompt`(构建系统提示词自动列表处): + +``` +遍历 skill 列表: + if is_dormant(skill) → 跳过,不注入自动列表 + else → 正常注入 +``` + +### 不受影响 + +- 用户手动 `/skill-name` — 执行正常,同时刷新 lastUsedAt +- 显式提及名 — 注入正常 +- path 匹配激活 — 激活正常,注入正常 +- `/skills` 面板 — 所有 skill 可见(含休眠,标注状态) +- `skill_view` — 不受过滤 + +--- + +## 8. 边界 + +| 场景 | 行为 | +|------|------| +| 新 skill 首次使用 | 自动建记录,`lastUsedAt` = 首次注入时间,`useCount` = 1,默认 active | +| skill 被卸载 | 状态记录保留,下次构建列表时自动跳过(文件不存在) | +| 状态文件损坏/丢失 | 全部 skill 视为无历史,下一轮从头累积,不影响功能 | +| 用户 pin 一个休眠 skill | 立即生效,下次构建列表时恢复出现 | +| 用户手动调用休眠 skill | 执行正常,`lastUsedAt` 刷新,下次会话自动恢复 active | +| daemon 和 TUI 同时写 | 原子写(临时文件+rename),后写者覆盖,不产生破损文件 | +| `idleDays = 0` | 关闭休眠,全部 active | +| 无 skill 的系统 | 状态文件为空/不存在,构建列表时无过滤,行为不变 | + +--- + +## 9. 实现路径 + +| # | 任务 | 涉及文件 | 说明 | +|---|------|----------|------| +| 1 | 新增 store | `src/skills/skill_usage_store.{hpp,cpp}` | 读/写状态文件,原子 rename,进程内 mutex | +| 2 | 记录钩子 | `src/agent_loop.cpp` + `src/skills/skill_activation.cpp` | `record_skill_usage(name)` 插入注入成功处 | +| 3 | 列表过滤 | `src/agent_loop.cpp`(`build_skills_index_context_prompt`) | 构建自动列表时按 `is_dormant` 跳过 | +| 4 | 配置项 | `src/config/` | 新增 `skills.idleDays`(默认 30) | +| 5 | 状态字段 | `src/tui_state.hpp` | 新增 `skill_usage_store` 引用 | +| 6 | TUI 展示 | `src/tui/`(settings 或 /skills 面板) | 次数/最后使用/状态/pin 操作 | +| 7 | Web 展示 | `src/web/`(开辟现有 API 或新增) | skill 状态 + pin/唤醒 | +| 8 | 测试 | `tests/skills/skill_usage_store_test.cpp` | 读/写/并发/边界/判定逻辑 | + +--- + +## 10. 测试清单 + +- [ ] 新建 skill → 自动创建记录,初始 active +- [ ] 注入后刷新 `lastUsedAt`,`useCount` 递增 +- [ ] 超阈值未用 → `is_dormant` = true,不进自动列表 +- [ ] pinned 的 skill 即使超阈值也保持 active +- [ ] 手动调用休眠 skill 后恢复 active +- [ ] 状态文件损坏时优雅降级,不影响功能 +- [ ] 原子写并发安全 +- [ ] `idleDays = 0` 关闭休眠 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md b/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md new file mode 100644 index 00000000..ff1285a2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-skill-dormancy-plan.md @@ -0,0 +1,623 @@ +# Skill 休眠(Dormancy)管理 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 skill 使用次数记录与自动休眠管理——闲置 skill 从系统提示词自动列表中隐藏,手动调用/path 匹配保留,调用后自动恢复。 + +**Architecture:** 新建 `skill_usage_store` 模块管理 `~/.acecode/.skill_usage_state.json`(进程内 mutex + 原子 rename),在 agent_loop 注入点记录使用,在 `build_skills_index_context_prompt` 构建列表时实时判定 `is_dormant` 过滤。配置项 `skills.idleDays` 进 SkillsConfig。 + +**Tech Stack:** C++17, nlohmann/json, GoogleTest, CMake/Ninja, 沿用 `atomic_write_file` 模式 + +**Spec:** `docs/superpowers/plans/2026-08-18-skill-dormancy-design.md` + +## Global Constraints + +- 遵循 `.editorconfig`:UTF-8, LF, 4 空格缩进 C++ +- 无 emoji/宽字符,ASCII 符号 +- 使用 `EXPECT_*` 断言(除非失败使后续不安全) +- 测试用 `fs::temp_directory_path()`,不写仓库树 +- 文件权限 0600,原子写(临时文件 + rename) +- `record_skill_usage` 是 best-effort,失败不阻断主流程 + +--- + +## File Structure + +| 文件 | 职责 | 新建/修改 | +|------|------|:---:| +| `src/skills/skill_usage_store.hpp` | 数据模型 + 公开接口声明 | 新建 | +| `src/skills/skill_usage_store.cpp` | store 实现:读/写/判定/并发 | 新建 | +| `src/agent_loop.cpp` | 注入点记录 + 列表过滤 | 修改 | +| `src/config/config.hpp` | `SkillsConfig` 新增 `idle_days` | 修改 | +| `src/tui_state.hpp` | 新增 store 引用 | 修改 | +| `src/main.cpp` | 初始化 store + 传参 | 修改 | +| `src/tui/settings/management_center.cpp` | TUI 展示次数/状态/pin | 修改 | +| `src/web/handlers/skills_handler.cpp` | Web API 返回状态 + pin | 修改 | +| `tests/skills/skill_usage_store_test.cpp` | store 单元测试 | 新建 | +| (顶层 CMake GLOB_RECURSE 自动收集 src/*.cpp,无需注册) | - | - | +| (tests/ GLOB 自动收集 *_test.cpp,无需注册) | - | - | + +--- + +### Task 1: 数据模型与 store 接口声明 + +**Files:** +- Create: `src/skills/skill_usage_store.hpp` + +**Interfaces:** +- Produces: `SkillUsageRecord`, `SkillUsageSummary`, `SkillUsageStore`, `parse_iso8601_to_epoch_ms` + +- [ ] **Step 1: 编写 header** + +```cpp +// src/skills/skill_usage_store.hpp +#pragma once + +#include +#include +#include +#include + +namespace acecode { + +struct SkillUsageRecord { + std::string last_used_at; // ISO8601 + std::uint64_t use_count = 0; + bool pinned = false; +}; + +struct SkillUsageSummary { + std::string name; + std::uint64_t use_count = 0; + std::string last_used_at; + bool pinned = false; + bool dormant = false; // 实时判定 +}; + +class SkillUsageStore { +public: + explicit SkillUsageStore(std::string state_path); + + bool record(const std::string& skill_name, const std::string& now_iso); + bool is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const; + bool set_pinned(const std::string& skill_name, bool pinned); + std::vector get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const; + void reload(); + +private: + std::string state_path_; + mutable std::mutex mu_; +}; + +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso); + +} // namespace acecode +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过(仅有声明,链接时符号缺失是预期) + +- [ ] **Step 3: Commit** + +```bash +git add src/skills/skill_usage_store.hpp +git commit -m "feat: add SkillUsageStore header with interface declarations" +``` + +--- + +### Task 2: Store 实现 + +**Files:** +- Create: `src/skills/skill_usage_store.cpp` +- Modify: `src/CMakeLists.txt` + +**Interfaces:** +- Consumes: `SkillUsageStore`, `SkillUsageRecord`, `SkillUsageSummary` from Task 1 +- Produces: full `SkillUsageStore` impl, `parse_iso8601_to_epoch_ms` + +- [ ] **Step 1: 编写实现** + +```cpp +// src/skills/skill_usage_store.cpp +#include "skills/skill_usage_store.hpp" +#include "utils/atomic_file.hpp" +#include "utils/logger.hpp" + +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace acecode { + +namespace { +constexpr int kStateVersion = 1; +constexpr std::size_t kMaxStateFileBytes = 1024 * 1024; // 1 MB + +nlohmann::json load_state_or_empty(const std::string& path) { + std::error_code ec; + if (!fs::exists(path, ec)) return nlohmann::json::object(); + if (fs::file_size(path, ec) > kMaxStateFileBytes) { + LOG_WARN("[skill_usage] state file too large, ignoring"); + return nlohmann::json::object(); + } + std::ifstream ifs(path); + if (!ifs) return nlohmann::json::object(); + try { + auto j = nlohmann::json::parse(ifs); + if (!j.is_object() || j.value("version", 0) != kStateVersion) { + LOG_WARN("[skill_usage] version mismatch, resetting"); + return nlohmann::json::object(); + } + return j; + } catch (const nlohmann::json::exception& e) { + LOG_WARN("[skill_usage] parse error: " + std::string(e.what())); + return nlohmann::json::object(); + } +} +} // namespace + +SkillUsageStore::SkillUsageStore(std::string state_path) + : state_path_(std::move(state_path)) {} + +bool SkillUsageStore::record(const std::string& skill_name, + const std::string& now_iso) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", now_iso}, + {"useCount", 1}, + {"pinned", false}}; + } else { + auto& entry = skills[skill_name]; + entry["lastUsedAt"] = now_iso; + entry["useCount"] = entry.value("useCount", 0u) + 1u; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +bool SkillUsageStore::is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const { + if (idle_days_ms == 0) return false; + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.contains(skill_name)) return false; + auto& entry = skills[skill_name]; + if (entry.value("pinned", false)) return false; + std::string last_used = entry.value("lastUsedAt", ""); + if (last_used.empty()) return false; + std::int64_t last_ms = parse_iso8601_to_epoch_ms(last_used); + if (last_ms == 0) return false; + return (now_epoch_ms - last_ms) > idle_days_ms; +} + +bool SkillUsageStore::set_pinned(const std::string& skill_name, bool pinned) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", ""}, + {"useCount", 0}, + {"pinned", pinned}}; + } else { + skills[skill_name]["pinned"] = pinned; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +std::vector SkillUsageStore::get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + std::vector out; + for (auto& [name, entry] : state["skills"].items()) { + SkillUsageSummary s; + s.name = name; + s.use_count = entry.value("useCount", 0u); + s.last_used_at = entry.value("lastUsedAt", ""); + s.pinned = entry.value("pinned", false); + if (idle_days_ms > 0 && !s.pinned && !s.last_used_at.empty()) { + std::int64_t last_ms = parse_iso8601_to_epoch_ms(s.last_used_at); + s.dormant = (last_ms > 0) && + (now_epoch_ms - last_ms) > idle_days_ms; + } + out.push_back(std::move(s)); + } + return out; +} + +void SkillUsageStore::reload() { + // load_state_or_empty on next access handles this +} + +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso) { + std::tm tm = {}; + std::istringstream ss(iso); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) return 0; + int ms = 0; + if (ss.peek() == '.') { ss.ignore(); ss >> ms; } + auto tp = std::chrono::system_clock::from_time_t( + std::mktime(&tm)) + std::chrono::milliseconds(ms); + return std::chrono::duration_cast( + tp.time_since_epoch()).count(); +} + +} // namespace acecode +``` + +- [ ] **Step 2: 编译验证(源文件由顶层 CMake GLOB_RECURSE 自动收集,无需注册)** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/skills/skill_usage_store.cpp +git commit -m "feat: implement SkillUsageStore with JSON read/write" +``` + +--- + +### Task 3: Store 单元测试 + +**Files:** +- Create: `tests/skills/skill_usage_store_test.cpp` +- Modify: `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: `SkillUsageStore`, `parse_iso8601_to_epoch_ms` from Task 2 + +- [ ] **Step 1: 编写测试** + +```cpp +#include "skills/skill_usage_store.hpp" +#include +#include +namespace fs = std::filesystem; + +TEST(SkillUsageStoreTest, ParseIso8601) { + auto ms = acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z"); + EXPECT_GT(ms, 0); + EXPECT_EQ(ms, acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z")); + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("not-a-date")); + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("")); +} + +TEST(SkillUsageStoreTest, RecordCreatesEntry) { + auto tmp = fs::temp_directory_path() / "acecode_test_skill_usage.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + EXPECT_TRUE(store.record("pdf", "2026-08-01T10:00:00Z")); + EXPECT_FALSE(store.is_dormant("pdf", 1722500000000LL, 30LL * 86400000)); + auto s = store.get_summary(1722500000000LL, 30LL * 86400000); + EXPECT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].name, "pdf"); + EXPECT_EQ(s[0].use_count, 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, DormantAfterThreshold) { + auto tmp = fs::temp_directory_path() / "acecode_test_dormant.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + std::int64_t used_ms = 1722500000000LL; + std::int64_t now_ms = used_ms + 40LL * 86400000; + std::int64_t idle_ms = 30LL * 86400000; + std::string used_iso = "2026-08-01T10:00:00Z"; + EXPECT_TRUE(store.record("xlsx", used_iso)); + EXPECT_TRUE(store.is_dormant("xlsx", now_ms, idle_ms)); + auto s = store.get_summary(now_ms, idle_ms); + EXPECT_EQ(s.size(), 1u); + EXPECT_TRUE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, PinnedSkillNeverDormant) { + auto tmp = fs::temp_directory_path() / "acecode_test_pinned.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + EXPECT_TRUE(store.record("pinned_skill", "2026-06-01T10:00:00Z")); + EXPECT_TRUE(store.set_pinned("pinned_skill", true)); + std::int64_t now_ms = 1730000000000LL; + std::int64_t idle_ms = 30LL * 86400000; + EXPECT_FALSE(store.is_dormant("pinned_skill", now_ms, idle_ms)); + auto s = store.get_summary(now_ms, idle_ms); + EXPECT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IdleDaysZeroDisablesFeature) { + auto tmp = fs::temp_directory_path() / "acecode_test_zero.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + EXPECT_TRUE(store.record("skill", "2020-01-01T00:00:00Z")); + EXPECT_FALSE(store.is_dormant("skill +", 0)); + auto s = store.get_summary(1730000000000LL, 0); + EXPECT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IncrementUseCount) { + auto tmp = fs::temp_directory_path() / "acecode_test_incr.json"; + fs::remove(tmp); + acecode::SkillUsageStore store(tmp.string()); + store.record("pdf", "2026-08-01T10:00:00Z"); + store.record("pdf", "2026-08-02T10:00:00Z"); + store.record("pdf", "2026-08-03T10:00:00Z"); + auto s = store.get_summary(1722500000000LL, 30LL * 86400000); + EXPECT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].use_count, 3u); + fs::remove(tmp); +} +``` + +- [ ] **Step 2: 运行测试(测试由 tests/ GLOB 自动收集进 acecode_unit_tests,无需注册)** + +Run: `cmake --build build --target acecode_unit_tests && ctest --test-dir build --output-on-failure -R skill_usage` +Expected: 7/7 tests pass + +- [ ] **Step 3: Commit** + +```bash +git add tests/skills/skill_usage_store_test.cpp +git commit -m "test: add SkillUsageStore unit tests" +``` + +--- + +### Task 4: 配置项 `skills.idleDays` + +**Files:** +- Modify: `src/config/config.hpp` + +**Interfaces:** +- Produces: `SkillsConfig::idle_days` (int, default 30) + +- [ ] **Step 1: 添加字段** + +在 `src/config/config.hpp` 的 `SkillsConfig` struct 中追加: + +```cpp +struct SkillsConfig { + std::vector disabled; + std::vector external_dirs; + bool reuse_opencode = true; + std::optional> allowed; + int idle_days = 30; // 新增:0=关闭休眠,>0=判定阈值 +}; +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/config/config.hpp +git commit -m "feat: add idle_days config to SkillsConfig" +``` + +--- + +### Task 5: 注入点记录 + 列表过滤 + +**Files:** +- Modify: `src/agent_loop.cpp` + +**Interfaces:** +- Consumes: `SkillUsageStore` from Task 2, `SkillsConfig::idle_days` from Task 4 +- Produces: injection recording + dormant filtering + +- [ ] **Step 1: 在注入成功处插入 `record_skill_usage`** + +在 `inject_explicit_skill_instructions` 返回后,对每个 `injected_skill_names` 调用: + +```cpp +// agent_loop.cpp, after inject_explicit_skill_instructions(...) +if (skill_usage_store_) { + auto now_iso = /* current time as ISO8601 */; + for (const auto& name : skill_expansion.injected_skill_names) { + skill_usage_store_->record(name, now_iso); + } +} +``` + +生成 ISO8601 的辅助: +```cpp +#include +#include +#include + +static std::string now_iso8601() { + auto now = std::chrono::system_clock::now(); + auto t = std::chrono::system_clock::to_time_t(now); + std::ostringstream oss; + oss << std::put_time(std::gmtime(&t), "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} +``` + +- [ ] **Step 2: 在 `build_skills_index_context_prompt` 处加过滤** + +调用处(agent_loop.cpp 约 957 行),在构建列表前: + +```cpp +auto now_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +auto idle_ms = config_ ? config_->skills().idle_days * 86400000LL : 0LL; + +auto skill_list = skill_registry_->list(); +std::vector active_skills; +for (const auto& s : skill_list) { + if (!skill_usage_store_ || + !skill_usage_store_->is_dormant(s.name, now_ms, idle_ms)) { + active_skills.push_back(s); + } +} +// 用 active_skills 替代原 skill_list 构建上下文 +``` + +- [ ] **Step 3: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 4: Commit** + +```bash +git add src/agent_loop.cpp +git commit -m "feat: record skill usage on injection and filter dormant skills" +``` + +--- + +### Task 6: 初始化与状态传递 + +**Files:** +- Modify: `src/tui_state.hpp` +- Modify: `src/main.cpp` + +**Interfaces:** +- Consumes: `SkillUsageStore` from Task 2 +- Produces: `tui_state.skill_usage_store` 可用 + +- [ ] **Step 1: 在 tui_state 添加 store 引用** + +```cpp +// src/tui_state.hpp, 在 slash_command_usage_counts 附近 +#include "skills/skill_usage_store.hpp" +// ... +std::shared_ptr skill_usage_store; +``` + +- [ ] **Step 2: 在 main.cpp 初始化 store** + +```cpp +// src/main.cpp, 在 skill_registry 初始化后 +auto home = /* acecode home dir */; +auto state_path = home + "/.skill_usage_state.json"; +state.skill_usage_store = std::make_shared(state_path); +``` + +- [ ] **Step 3: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 4: Commit** + +```bash +git add src/tui_state.hpp src/main.cpp +git commit -m "feat: wire SkillUsageStore into tui_state and main" +``` + +--- + +### Task 7: TUI 展示 + +**Files:** +- Modify: `src/tui/settings/management_center.cpp` + +- [ ] **Step 1: 在 /skills 面板或管理中心的 skill 列表追加状态列** + +在现有 skill 列表渲染处,为每个 skill 追加: + +```cpp +// 在显示每个 skill 的行末追加 +auto summary = skill_usage_store_->get_summary( + now_epoch_ms(), + config_.skills().idle_days * 86400000LL); +for (const auto& s : summary) { + std::string status = s.dormant ? " [dormant]" + : s.pinned ? " [pinned]" + : " [active]"; + std::string info = " used " + std::to_string(s.use_count) + + " times, last " + s.last_used_at; + // 渲染到 TUI 行 +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/tui/settings/management_center.cpp +git commit -m "feat: display skill usage stats and dormant status in TUI" +``` + +--- + +### Task 8: Web 展示 + +**Files:** +- Modify: `src/web/handlers/skills_handler.cpp` + +- [ ] **Step 1: 在现有 GET /skills API 响应中追加 usage 字段** + +```cpp +// 在 skills_handler 的 JSON 响应中追加 +nlohmann::json usage_array = nlohmann::json::array(); +if (skill_usage_store_) { + for (const auto& s : skill_usage_store_->get_summary( + now_epoch_ms(), idle_days_ms)) { + usage_array.push_back({ + {"name", s.name}, + {"useCount", s.use_count}, + {"lastUsedAt", s.last_used_at}, + {"pinned", s.pinned}, + {"dormant", s.dormant} + }); + } +} +response["usage"] = std::move(usage_array); +``` + +- [ ] **Step 2: 编译验证** + +Run: `cmake --build build --target acecode --config Release` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add src/web/handlers/skills_handler.cpp +git commit -m "feat: expose skill usage stats via Web API" +``` + +--- + +## Verification + +完成所有 8 个 task 后: + +```bash +cmake --build build --target acecode_unit_tests --config Release +ctest --test-dir build --output-on-failure +``` + +预期:所有现有测试 + 新增 `acecode_skill_usage_store_test` 全部通过。 diff --git a/docs/tui-comparison/demos/.gitignore b/docs/tui-comparison/demos/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/docs/tui-comparison/demos/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/docs/tui-comparison/demos/01_synchronized_output.py b/docs/tui-comparison/demos/01_synchronized_output.py new file mode 100644 index 00000000..13cbb508 --- /dev/null +++ b/docs/tui-comparison/demos/01_synchronized_output.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""01 - CSI 2026 同步输出(Synchronized Update)对比 + +报告章节:3.1 / 3.6 +acecode 现状:❌ 无,FTXUI 用 \\033[1A 光标回退逐帧重绘,无同步输出协议 +有此协议:pi(TuiMainScreen/TuiAltScreen 整帧包 CSI 2026)、grok-build(draw.rs 每帧包) + +演示:快速重绘一个 10 行计数块。 + 阶段 A:不加同步 -- 可见闪烁/撕裂(光标在行间跳动可见) + 阶段 B:用 \\x1b[?2026h...\\x1b[?2026l 包裹整帧 -- 平滑无闪烁 + +在刷新率高的终端上差异最明显;慢终端上 A 阶段撕裂更明显。 +""" + +import sys +import time +from _term import ( + init, CLEAR, RESET, HIDE_CURSOR, SHOW_CURSOR, + SYNC_BEGIN, SYNC_END, goto, CLEAR_LINE, fg, bg, BOLD, +) + +init() + +LINES = 10 +COLS = 48 +TOP = 3 + + +def render_block(frame, sync): + """渲染 10 行计数块。每行一个独立计数器,模拟 TUI 多区域同时刷新。""" + parts = [] + if sync: + parts.append(SYNC_BEGIN) + for i in range(LINES): + row = TOP + i + v = (frame * (i + 1)) % 1000 + bar = "█" * (v % 30) + "░" * (30 - v % 30) + # 不同行不同颜色,模拟多组件 + c = [(100, 200, 255), (255, 200, 100), (180, 255, 140), (220, 140, 255)][i % 4] + parts.append(goto(row, 4) + CLEAR_LINE + bg(30, 30, 40) + fg(*c) + + f" line {i:2d} | {bar} | {v:4d} " + RESET) + if sync: + parts.append(SYNC_END) + sys.stdout.write("".join(parts)) + sys.stdout.flush() + + +def phase(label, sync, frames=80): + sys.stdout.write(goto(1, 1) + CLEAR_LINE + BOLD + f" {label}" + RESET) + for f in range(frames): + render_block(f, sync) + time.sleep(0.016) # ~60fps + # 留白 + time.sleep(0.4) + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + phase("阶段 A: 无同步输出(acecode 现状 -- 注意闪烁/撕裂) ", sync=False) + phase("阶段 B: CSI 2026 同步输出(pi/grok -- 平滑无闪烁) ", sync=True) + sys.stdout.write(goto(TOP + LINES + 2, 1) + RESET + + "对比结论:同步输出把整帧原子化提交,终端不会出现半帧状态。\n" + "acecode 的 FTXUI 用 \\033[1A 光标回退逐行重绘,在高刷新率/慢终端上会闪烁。\n") + finally: + sys.stdout.write(SHOW_CURSOR + RESET + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/02_osc8_hyperlinks.py b/docs/tui-comparison/demos/02_osc8_hyperlinks.py new file mode 100644 index 00000000..ce49c34f --- /dev/null +++ b/docs/tui-comparison/demos/02_osc8_hyperlinks.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""02 - OSC 8 可点击超链接对比 + +报告章节:3.6 +acecode 现状:⚠️ 有检测代码(mardown_formatter.cpp:78 terminal_supports_hyperlinks) + 但 make_hyperlink() 注明 "FTXUI Elements 无法内嵌 OSC 8",只返回纯文本 -- 链接不可点击 +有此能力:opencode、pi、grok-build(grok 还自动 linkify 文件路径/URL) + +演示:打印几条链接(URL / file:// 路径),对比: + 上半:OSC 8 超链接 -- 支持的终端里 Ctrl/Cmd+Click 可打开 + 下半:acecode 现状 -- 纯色文本,不可点击(下划线只是颜色装饰) + +OSC 8 格式: \\x1b]8;;URL\\x07 显示文本 \\x1b]8;;\\x07 +""" + +import sys +import os +from _term import init, RESET, fg, UNDERLINE, BOLD, DIM + +init() + + +def osc8(url, text): + return f"\x1b]8;;{url}\x07{text}\x1b]8;;\x07" + + +def main(): + sample_file = os.path.abspath(__file__).replace("\\", "/") + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(sample_file))) + repo_file = "file:///" + os.path.join(repo_root, "src", "main.cpp").replace("\\", "/") + + print(BOLD + "=== OSC 8 可点击超链接(opencode / pi / grok 有此能力)===" + RESET) + print() + print(" " + osc8("https://github.com/charmbracelet/crush", "crush (GitHub)")) + print(" " + osc8("https://sw.kovidgoyal.net/kitty/keyboard-protocol/", "kitty keyboard 协议文档")) + print(" " + osc8(repo_file, "src/main.cpp(点击在编辑器打开)")) + print(" " + osc8("https://example.com/path?q=1", "带查询参数的 URL")) + print() + print(DIM + " ↑ 在 Windows Terminal / kitty / WezTerm / iTerm2 里 Ctrl+Click(或 Cmd+Click)可打开" + RESET) + print() + + print(BOLD + "=== acecode 现状:纯色文本,不可点击 ===" + RESET) + print() + print(" " + fg(100, 180, 255) + UNDERLINE + "crush (GitHub)" + RESET + + DIM + " https://github.com/charmbracelet/crush" + RESET) + print(" " + fg(100, 180, 255) + UNDERLINE + "kitty keyboard 协议文档" + RESET + + DIM + " https://sw.kovidgoyal.net/kitty/keyboard-protocol/" + RESET) + print(" " + fg(100, 180, 255) + UNDERLINE + "src/main.cpp" + RESET + + DIM + " " + repo_file + RESET) + print() + print(DIM + " ↑ acecode 的 markdown_formatter 检测到支持 OSC 8 的终端后," + "仍只渲染带下划线的蓝色文本 -- make_hyperlink() 因 FTXUI Element" + RESET) + print(DIM + " 网格模型无法内嵌原始 OSC 8 序列而退化。这是架构限制,非简单 bug。" + RESET) + print() + print(BOLD + "为何 FTXUI 做不到:" + RESET) + print(" FTXUI 的渲染管线把组件树 rasterize 成字符网格(Screen),每个 cell 只存" + "字符+颜色,无法在某段文本中间插入 OSC 8 的开始/结束标记(那会破坏网格对齐)。") + print(" opencode/grok 用自己的 framebuffer/ratatui,可以在 cell 之外维护一个 link 层" + "(grok 的 flush_with_links 把链接变化与 cell 变化一起 diff)。") + print() + print(BOLD + "acecode 可行的接入路径:" + RESET) + print(" 1. 在 FTXUI Screen rasterize 之后、写终端之前,做一遍后处理:" + "扫描带 link 标记的 cell 区间,用 OSC 8 包裹(需 FTXUI 暴露 link 元数据)。") + print(" 2. 或在 markdown_formatter 阶段直接产出原始 ANSI 文本(绕过 FTXUI Element)," + "像 grok 那样在 transcript 区用自绘文本而非 FTXUI 组件。") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/03_kitty_keyboard.py b/docs/tui-comparison/demos/03_kitty_keyboard.py new file mode 100644 index 00000000..aba2ba90 --- /dev/null +++ b/docs/tui-comparison/demos/03_kitty_keyboard.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""03 - kitty keyboard 协议(交互式) + +报告章节:3.6 +acecode 现状:❌ 仅消费标准 CSI 修饰符(\\x1B[1;3A 等),未启用 kitty 协议 +有此能力:opencode、pi、grok-build + +kitty keyboard 协议启用后: + - Enter -> \\x1b[13u (而非裸 \\r,可区分) + - Shift+Enter -> \\x1b[13;2u (修饰符 2=Shift) + - Ctrl+A -> \\x1b[97;5u (修饰符 5=Ctrl) + - Alt+X -> \\x1b[120;3u(修饰符 3=Alt) + - 按键释放事件也可上报(flag 2) + +acecode 在 Windows Terminal 上拿不到 Shift+Enter(WT 不支持 kitty),靠 IME 脏补丁; +pi 用原生 addon(GetAsyncKeyState)查全局修饰键补这个缺口。 + +演示: + 1. 打印键码参考表 + 2. 进入 raw 模式 + 启用 kitty 协议,实时显示按键的原始字节 + 3. 按 q 退出 + +注意:Windows Terminal 目前不支持 kitty 协议,启用后仍回退标准序列 -- 这本身就是 + 演示的一部分(能看到 WT 发的是 \\x1b[1;3A 而非 \\x1b[13;2u)。 + kitty / WezTerm / Ghostty 上能看到完整 CSI u 序列。 +""" + +import sys +from _term import ( + init, RESET, BOLD, DIM, fg, CLEAR, HIDE_CURSOR, SHOW_CURSOR, + raw_mode, read_key, KITTY_PUSH, KITTY_POP, goto, +) + +init() + + +def fmt_bytes(b): + """把字节格式化为可读的转义序列表示。""" + out = [] + for byte in b: + if byte == 0x1b: + out.append(fg(255, 100, 100) + "\\x1b" + RESET) + elif byte == 0x0d: + out.append(fg(255, 200, 100) + "\\r" + RESET) + elif byte == 0x0a: + out.append(fg(255, 200, 100) + "\\n" + RESET) + elif byte == 0x07: + out.append(fg(255, 200, 100) + "\\x07" + RESET) + elif 32 <= byte < 127: + out.append(chr(byte)) + else: + out.append(fg(200, 200, 200) + f"\\x{byte:02x}" + RESET) + return "".join(out) + + +def reference_table(): + print(BOLD + "=== 键码参考:标准序列 vs kitty CSI u 序列 ===" + RESET) + print() + print(f" {'按键':<16} {'标准(acecode 消费)':<24} {'kitty 协议':<20} {'可区分?'}") + print(f" {'-'*16} {'-'*24} {'-'*20} {'-'*8}") + rows = [ + ("Enter", "\\r", "\\x1b[13u", "✓(与 Shift 区分)"), + ("Shift+Enter","\\r(同 Enter!)", "\\x1b[13;2u", "✓"), + ("Ctrl+A", "\\x01", "\\x1b[97;5u", "✓"), + ("Alt+X", "\\x1bx", "\\x1b[120;3u", "✓"), + ("Shift+Tab", "\\x1b[Z", "\\x1b[9;2u", "✓(标准也行)"), + ("Ctrl+Shift+↑","\\x1b[1;6A", "\\x1b[1;6;1u", "✓ + 事件类型"), + ("释放事件", "(无)", "\\x1b[...;1u", "✓(flag 2)"), + ] + for key, std, kitty, note in rows: + print(f" {key:<16} {fg(180,180,180)}{std:<24}{RESET} {fg(120,200,255)}{kitty:<20}{RESET} {note}") + print() + print(DIM + " acecode 现状:消费 \\x1B[1;3A(Alt+↑)等标准序列,但未启用 kitty 协议," + RESET) + print(DIM + " 所以 Shift+Enter 在多数终端拿不到(与 Enter 都是 \\r)。pi 用原生 addon 查" + RESET) + print(DIM + " GetAsyncKeyState 补这个缺口;opencode/grok 直接用 kitty 协议。" + RESET) + print() + + +def live_capture(): + print(BOLD + "=== 实时捕获(按 q 退出)===" + RESET) + print(DIM + " 试试 Enter / Shift+Enter / Ctrl+L / Alt+X / 方向键,看原始字节" + RESET) + print(DIM + " 启用了 kitty 协议(\\x1b[>15u);不支持的终端会回退标准序列" + RESET) + print() + + # 启用 kitty 协议 + sys.stdout.write(KITTY_PUSH) + sys.stdout.flush() + + row = 10 + try: + while True: + sys.stdout.write(goto(row, 1) + fg(120, 200, 255) + " ❯ " + RESET + "等待按键..." + " " * 30) + sys.stdout.flush() + b = read_key() + if not b: + continue + display = fmt_bytes(b) + # q 退出(但避免误判带修饰的 q) + if b == b"q": + break + sys.stdout.write(goto(row, 1) + fg(120, 200, 255) + " ❯ " + RESET + + display + " " + DIM + f"({len(b)} bytes)" + RESET + " " * 10) + sys.stdout.flush() + row += 1 + if row > 25: + row = 10 + finally: + sys.stdout.write(KITTY_POP + "\n") + sys.stdout.flush() + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + reference_table() + print(BOLD + "进入 raw 模式捕获按键..." + RESET) + with raw_mode(): + live_capture() + finally: + sys.stdout.write(SHOW_CURSOR + RESET + "\n") + sys.stdout.flush() + print() + print(BOLD + "观察要点:" + RESET) + print(" - 在 kitty/WezTerm/Ghostty 上,Shift+Enter 应显示 \\x1b[13;2u") + print(" - 在 Windows Terminal 上,Shift+Enter 可能仍是 \\r(kitty 不支持)--> 这正是 acecode 的困境") + print(" - acecode 若启用 kitty(或至少 modifyOtherKeys),可在支持的终端可靠区分修饰键") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/04_alpha_transparency.py b/docs/tui-comparison/demos/04_alpha_transparency.py new file mode 100644 index 00000000..2d6fd93d --- /dev/null +++ b/docs/tui-comparison/demos/04_alpha_transparency.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""04 - RGBA alpha 透明混色对比 + +报告章节:3.1 / 3.4 +acecode 现状:❌ FTXUI 颜色不透明(OPAQUE),无法做半透明叠层 +有此能力:opencode(RGBA alpha 混色,半透明对话框遮罩、透出终端的 system 主题)、 + grok-build(framebuffer 合成) + +ANSI 本身无法逐 cell 设 alpha。opencode/grok 在 framebuffer 层做 per-pixel alpha 合成: + result = bg * (1 - alpha) + overlay * alpha +先把叠层与背景混色,再输出最终颜色。本演示用预混色模拟这个视觉效果。 + +演示: + 左:acecode 现状 -- 不透明遮罩完全盖住背景文字 + 右:opencode/grok -- 半透明遮罩,背景文字隐约可见(预混色模拟) +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg, bg, goto, CLEAR, HIDE_CURSOR, SHOW_CURSOR, blend + +init() + + +def render_row(row, label, alpha): + """在第 row 行演示一个遮罩盖住背景文字的效果。 + + acecode(不透明):遮罩色直接盖死背景。 + opencode(半透明):遮罩色与背景文字色按 alpha 预混,文字隐约可见。 + """ + bg_text = "这是一段背景文字 transcript content streaming here..." + bg_color = (60, 60, 70) # 深灰背景文字 + panel_color = (40, 120, 200) # 蓝色对话框遮罩 + + # 标签 + sys.stdout.write(goto(row, 1) + BOLD + f"{label:<28}" + RESET) + + col = 30 + # 背景文字(裸色) + sys.stdout.write(goto(row, col)) + for ch in bg_text[:40]: + sys.stdout.write(bg(30, 30, 38) + fg(*bg_color) + ch) + sys.stdout.write(RESET) + + # 遮罩盖在前 20 个字符上 + if alpha >= 1.0: + # acecode: 不透明,完全盖死 + sys.stdout.write(goto(row, col)) + for ch in bg_text[:20]: + sys.stdout.write(bg(*panel_color) + " " ) + sys.stdout.write(RESET + fg(255, 255, 255) + BOLD) + sys.stdout.write(goto(row, col + 2) + "[遮罩完全盖住]") + else: + # opencode: 半透明,背景文字与遮罩预混 + sys.stdout.write(goto(row, col)) + mixed_bg = blend((30, 30, 38), panel_color, alpha) + mixed_fg = blend(bg_color, (255, 255, 255), alpha) + for ch in bg_text[:20]: + sys.stdout.write(bg(*mixed_bg) + fg(*mixed_fg) + ch) + sys.stdout.write(RESET + fg(255, 255, 255) + BOLD) + sys.stdout.write(goto(row, col + 2) + "[半透明]") + sys.stdout.write(RESET) + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + sys.stdout.write(goto(1, 1) + BOLD + + "RGBA alpha 透明混色对比(opencode/grok 有,acecode 无)" + RESET) + sys.stdout.write(goto(2, 1) + DIM + + "ANSI 无法逐 cell 设 alpha;opencode/grok 在 framebuffer 层 per-pixel 合成。" + "这里用预混色模拟视觉效果。" + RESET) + + # 三个 alpha 档位渐变 + labels = [ + (5, "acecode(不透明 α=1.0)", 1.0), + (8, "opencode(半透明 α=0.5)", 0.5), + (11, "opencode(半透明 α=0.3)", 0.3), + ] + for row, label, a in labels: + render_row(row, label, a) + + # 动画:alpha 从 0.2 渐变到 0.8 再回来,展示"呼吸"的透明度 + sys.stdout.write(goto(14, 1) + BOLD + "动态 alpha 渐变(模拟对话框淡入):" + RESET) + import math + for frame in range(120): + t = frame / 120.0 + a = 0.3 + 0.4 * (0.5 + 0.5 * math.sin(t * math.pi * 4)) + render_row(16, f"α={a:.2f}", a) + time.sleep(0.03) + + sys.stdout.write(goto(19, 1) + RESET + BOLD + "结论:" + RESET) + sys.stdout.write(goto(20, 1) + " acecode 的 FTXUI Color 无 alpha 通道,遮罩只能完全盖住或完全透明。") + sys.stdout.write(goto(21, 1) + " opencode 的 RGBA(如 RGBA.fromInts(0,0,0,150))可做半透明遮罩,") + sys.stdout.write(goto(22, 1) + " 配合 tint() 线性混色实现对话框/气泡的层次感。") + sys.stdout.write(goto(23, 1) + " grok 在 ratatui 之上自建 framebuffer 合成层达成同样效果。") + finally: + sys.stdout.write(goto(25, 1) + RESET + SHOW_CURSOR) + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/05_animated_background.py b/docs/tui-comparison/demos/05_animated_background.py new file mode 100644 index 00000000..ccdaf0a2 --- /dev/null +++ b/docs/tui-comparison/demos/05_animated_background.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""05 - 正弦呼吸动画背景 + +报告章节:3.4 +acecode 现状:❌ 无背景动画(仅 compact_animation / thinking_heartbeat 文本) +有此能力: + opencode -- BgPulse 逐帧程序化动画背景(正弦呼吸光圈、GO logo 高光扫动、临时降频 30fps) + grok-build -- wave_brightness(tick, row) sin² 空间相位波(跨行扫过)+ pulse_brightness + +演示两段: + [A] 全屏 sin² 呼吸:背景亮度随 sin²(t) 循环(opencode BgPulse 风格) + [B] 跨行相位波:每行相位偏移,形成自上而下扫过的波(grok wave_brightness 风格) + +Ctrl+C 退出。 +""" + +import sys +import math +import time +from _term import init, RESET, BOLD, DIM, fg, bg, goto, CLEAR, HIDE_CURSOR, SHOW_CURSOR + +init() + +ROWS = 12 +COLS = 60 +TOP = 4 + + +def lerp(a, b, t): + return a + (b - a) * t + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + sys.stdout.write(goto(1, 1) + BOLD + + "正弦呼吸动画背景(opencode BgPulse / grok wave_brightness)" + RESET) + sys.stdout.write(goto(2, 1) + DIM + "[A] 全屏呼吸 [B] 跨行相位波 Ctrl+C 退出" + RESET) + + start = time.time() + phase = "A" + switch_at = 4.0 + while True: + elapsed = time.time() - start + if elapsed > switch_at: + phase = "B" if phase == "A" else "A" + switch_at = elapsed + 4.0 + label = "全屏 sin² 呼吸(opencode BgPulse)" if phase == "A" else "跨行相位波(grok wave_brightness)" + sys.stdout.write(goto(3, 1) + DIM + f"[{phase}] {label}" + RESET + " " * 20) + + t = elapsed + buf = [] + for r in range(ROWS): + row = TOP + r + if phase == "A": + # 全屏同相呼吸 + brightness = 0.5 + 0.5 * math.sin(t * 2.0) + else: + # 跨行相位波:每行相位偏移 + brightness = 0.5 + 0.5 * math.sin(t * 2.0 - r * 0.5) + # 背景色从深蓝到亮青 + br = lerp(20, 80, brightness) + bgc = lerp(30, 180, brightness) + bgr = lerp(15, 40, brightness) + text_brightness = lerp(120, 255, brightness) + buf.append(goto(row, 4)) + line_text = f" row {r:2d} ░▒▓█ wave demo █▓▒░ brightness={brightness:.2f} " + buf.append(bg(int(bgr), int(br), int(bgc))) + buf.append(fg(int(text_brightness), int(text_brightness), int(text_brightness))) + buf.append(line_text + RESET) + sys.stdout.write("".join(buf)) + sys.stdout.flush() + time.sleep(0.033) # ~30fps + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(goto(TOP + ROWS + 2, 1) + RESET + SHOW_CURSOR) + sys.stdout.write("结论:背景动画需逐帧重绘每个 cell 的背景色," + "FTXUI 的 Screen 重绘可做但 acecode 未实现;opencode/grok 把动画" + "与运行状态耦合进渲染管线(运行中 block 的 accent 柱做波浪/脉冲)。\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/06_spinner_showcase.py b/docs/tui-comparison/demos/06_spinner_showcase.py new file mode 100644 index 00000000..1b2c6473 --- /dev/null +++ b/docs/tui-comparison/demos/06_spinner_showcase.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""06 - 五种 Spinner 同屏对比 + +报告章节:3.4 +acecode 现状:❌ 仅 compact_animation,无丰富 spinner +其他项目: + opencode -- Knight Rider 扫描光带(逐像素 alpha 渐变拖尾、菱形/方块、双向扫描+端点停留) + grok-build -- 三套帧集:braille ⠋⠙⠹⠸⠼⠴⠦⠧ / dot ⋅ : ⸬ ⁙ / monitor ○ ◎ ◉ ◎ + pi -- loader.ts 盲文帧(⠋⠙⠹...,80ms) + +演示:5 个 spinner 同屏旋转,标注来源。Ctrl+C 退出。 +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg, goto, CLEAR, HIDE_CURSOR, SHOW_CURSOR + +init() + +# ---- 帧集 ---- +BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"] # grok / pi +DOT = ["⋅", ":", "⸬", "⁙"] # grok +MONITOR = ["○", "◎", "◉", "◎"] # grok + +# Knight Rider 扫描光带(opencode ui/spinner.ts 风格) +KR_WIDTH = 10 +def knight_rider(frame): + pos = frame % (KR_WIDTH * 2 - 2) + if pos >= KR_WIDTH: + pos = KR_WIDTH * 2 - 2 - pos + bar = ["░"] * KR_WIDTH + # 主光点 + 拖尾(alpha 渐变用不同亮度字符模拟) + trail = ["█", "▓", "▒", "░"] + for i, ch in enumerate(trail): + idx = pos - i + if 0 <= idx < KR_WIDTH: + bar[idx] = ch + return "".join(bar) + +# acecode 风格:静态 ●(或 compact_animation 的简单脉冲) +def acecode_dot(frame): + # 模拟 compact_animation: ● 亮度脉冲 + bright = (frame % 4) in (0, 1) + return fg(200, 200, 200) if bright else fg(120, 120, 120) + "●" + RESET + + +SPINNERS = [ + ("acecode", "●(compact_animation 脉冲)", lambda f: acecode_dot(f)), + ("grok", "braille ⠋⠙⠹⠸⠼⠴⠦⠧", lambda f: fg(120, 200, 255) + BRAILLE[f % len(BRAILLE)] + RESET), + ("grok", "dot ⋅ : ⸬ ⁙", lambda f: fg(255, 200, 100) + DOT[f % len(DOT)] + RESET), + ("grok", "monitor ○ ◎ ◉ ◎", lambda f: fg(180, 255, 140) + MONITOR[f % len(MONITOR)] + RESET), + ("opencode", "Knight Rider 扫描光带", lambda f: fg(255, 80, 80) + knight_rider(f) + RESET), +] + + +def main(): + sys.stdout.write(CLEAR + HIDE_CURSOR) + try: + sys.stdout.write(goto(1, 1) + BOLD + "Spinner 同屏对比(acecode 无丰富 spinner)" + RESET) + sys.stdout.write(goto(2, 1) + DIM + "Ctrl+C 退出" + RESET) + frame = 0 + while True: + buf = [] + for i, (src, desc, fn) in enumerate(SPINNERS): + row = 4 + i + buf.append(goto(row, 3)) + buf.append(f"{fn(frame)} {BOLD}{src:<10}{RESET} {DIM}{desc}{RESET}") + # 状态文本 + buf.append(goto(4 + len(SPINNERS) + 1, 3)) + buf.append(DIM + f"frame={frame} 各 spinner 独立帧率/方向" + RESET + " " * 20) + sys.stdout.write("".join(buf)) + sys.stdout.flush() + frame += 1 + time.sleep(0.08) # ~12fps,盲文帧 80ms 对齐 + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(goto(4 + len(SPINNERS) + 3, 1) + RESET + SHOW_CURSOR) + sys.stdout.write("结论:acecode 仅 compact_animation,无多套 spinner 帧集;\n" + "opencode 的 Knight Rider 用逐像素 alpha 渐变(FTXUI 不透明色做不到拖尾衰减),\n" + "grok 三套帧集按 legacy ConHost 回退 ASCII。\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/07_osc133_prompts.py b/docs/tui-comparison/demos/07_osc133_prompts.py new file mode 100644 index 00000000..f2ae0c03 --- /dev/null +++ b/docs/tui-comparison/demos/07_osc133_prompts.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""07 - OSC 133 Prompt 语义标记 + +报告章节:3.6 +acecode 现状:❌ 无 +有此能力:pi(用 \\x1b]133;A/B/C 包裹每个 turn,支持 scrollToPrompt 上下跳转) + +OSC 133 标记: + \\x1b]133;A\\x07 prompt 开始(用户输入区起点) + \\x1b]133;B\\x07 prompt 结束 / 输出开始 + \\x1b]133;C\\x07 输出结束 + +支持的终端(kitty / WezTerm / Ghostty)提供 Cmd+Shift+↑/↓ 在 prompt 边界间跳转, +类似 shell 里的 prompt 边界导航。pi 借此在 transcript 里按"回合"快速跳转。 + +演示:打印几轮对话,每轮用 OSC 133 标记边界。在 kitty/WezTerm 里试 Cmd+Shift+↑/↓。 +然后对比 acecode 的无标记纯文本(无法按回合跳转,只能逐行滚)。 +""" + +import sys +from _term import init, RESET, fg, BOLD, DIM, fg256 + +init() + +A = "\x1b]133;A\x07" # prompt start +B = "\x1b]133;B\x07" # prompt end / output start +C = "\x1b]133;C\x07" # output end + + +def user_turn(label, text): + # 用户输入区: A ... B + return (A + fg(86, 156, 214) + BOLD + f"❯ {label}: " + text + RESET + B) + + +def assistant_out(text): + return "\n" + fg(220, 220, 170) + text + RESET + "\n" + C + + +def main(): + print(BOLD + "=== OSC 133 Prompt 语义标记(pi 有,acecode 无)===" + RESET) + print() + print(DIM + "在 kitty/WezTerm/Ghostty 里试 Cmd+Shift+↑/↓(或终端的 prompt 跳转快捷键)" + RESET) + print(DIM + "光标会在每个 ❯ 之间跳转;acecode 无此标记,只能逐行滚。" + RESET) + print() + + # 三轮带标记的对话 + sys.stdout.write(user_turn("用户", "解释一下同步输出")) + sys.stdout.write(assistant_out("CSI 2026 同步输出把整帧原子化提交,终端不会显示半帧状态。")) + sys.stdout.write("\n") + + sys.stdout.write(user_turn("用户", "kitty keyboard 协议有什么用")) + sys.stdout.write(assistant_out("它让 Shift+Enter、Ctrl+组合等修饰键以明确的 CSI u 序列上报," + "而不是无法区分的裸 \\r。")) + sys.stdout.write("\n") + + sys.stdout.write(user_turn("用户", "acecode 为何没接入")) + sys.stdout.write(assistant_out("FTXUI 的 Element 网格模型难以承载 prompt 边界这类跨 cell 的" + "语义标记,且 acecode 未在 rasterize 后注入 OSC 133。")) + sys.stdout.write("\n\n") + + print(BOLD + "=== acecode 现状:无标记纯文本(无法按回合跳转)===" + RESET) + print() + print(fg(86, 156, 214) + BOLD + "❯ 用户: 解释一下同步输出" + RESET) + print(fg(220, 220, 170) + "CSI 2026 同步输出把整帧原子化提交。" + RESET) + print() + print(fg(86, 156, 214) + BOLD + "❯ 用户: kitty keyboard 协议有什么用" + RESET) + print(fg(220, 220, 170) + "它让修饰键以明确的 CSI u 序列上报。" + RESET) + print() + print(DIM + "↑ 视觉上一样,但缺 OSC 133 标记 -- 终端不知道哪里是回合边界,无法提供跳转。" + RESET) + print() + print(BOLD + "接入难度:低。" + RESET) + print("OSC 133 是纯文本序列,不依赖 cell 网格。acecode 只需在 user 消息渲染前后、" + "assistant 输出前后各 print 一对标记即可(在 FTXUI 外层或 transcript 行输出时注入)。") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/08_tool_row_dots.py b/docs/tui-comparison/demos/08_tool_row_dots.py new file mode 100644 index 00000000..c2f0f675 --- /dev/null +++ b/docs/tui-comparison/demos/08_tool_row_dots.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""08 - acecode 工具行 ● 三态指示灯(本项目独有优势) + +报告章节:二 / 四.3 +acecode 现状:✅ 独有风格 -- compute_tool_call_dots FIFO 配对 tool_call ↔ tool_result + ● 灰=执行中 / 绿=成功 / 红=失败,pascal_case_tool_name 加粗,参数预览 + +对比其他项目: + opencode: 消息卡片左侧竖线,工具为文本 part + pi: toolPendingBg/toolSuccessBg/toolErrorBg 背景色块 + grok: 左侧 accent 竖线(accent_bar ┃),block 折叠/截断 + +演示:模拟一段 transcript,展示 acecode 的: + - 工具调用行 ● ToolName(args) + - 配对结果行(缩进 └ + 摘要) + - 三态指示灯(执行中灰 / 成功绿 / 失败红) + - 孤儿调用(无结果,保持灰) + - 并行调用 FIFO 配对 +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg, goto, CLEAR_LINE, HIDE_CURSOR, SHOW_CURSOR + +init() + +# 指示灯颜色(对齐 acecode 三态语义) +DOT_WORKING = fg(160, 160, 160) # 灰=执行中 +DOT_SUCCESS = fg(120, 200, 120) # 绿=成功 +DOT_FAILED = fg(230, 100, 100) # 红=失败 +TOOL_COLOR = fg(180, 140, 220) # syntax.preproc 紫 +SUMMARY = DIM +RESULT_PREFIX = " └ " + + +def tool_call(name, args, dot=DOT_WORKING): + return f"{dot}●{RESET} {TOOL_COLOR}{BOLD}{name}{RESET}{DIM}({args}){RESET}" + + +def tool_result(text, ok=True): + color = fg(160, 200, 160) if ok else fg(230, 130, 130) + return f"{RESULT_PREFIX}{color}{text}{RESET}" + + +def line(text): + sys.stdout.write(text + "\n") + + +def main(): + print(BOLD + "=== acecode 工具行 ● 三态指示灯(独有优势)===" + RESET) + print(DIM + "对齐 Claude Code 风格: ● ToolName(args) + 缩进 └ 结果摘要" + RESET) + print() + + print(BOLD + "[1] 三态指示灯" + RESET) + line(tool_call("Bash", "command=\"npm test\"", dot=DOT_WORKING) + " " + DIM + "← 执行中(灰)" + RESET) + time.sleep(0.3) + line(tool_result("3 passed", ok=True) + " " + DIM + "← 成功(绿)" + RESET) + line(tool_call("FileWrite", "path=src/main.cpp", dot=DOT_WORKING)) + time.sleep(0.3) + line(tool_result("Error: permission denied", ok=False) + " " + DIM + "← 失败(红)" + RESET) + line(tool_call("Grep", "pattern=TODO") + " " + DIM + "← 孤儿(无结果,保持灰)" + RESET) + print() + + print(BOLD + "[2] 并行调用 FIFO 配对(compute_tool_call_dots)" + RESET) + print(DIM + "三个并行读工具同时发出,结果按完成顺序回收,指示灯逐个变绿:" + RESET) + line(tool_call("FileRead", "path=a.cpp")) + line(tool_call("FileRead", "path=b.cpp")) + line(tool_call("FileRead", "path=c.cpp")) + # 模拟逐个完成 -- 但因为是 FIFO 配对,只有最前的那个能变绿 + time.sleep(0.4) + # 用回退光标重写第一行为成功 + sys.stdout.write("\x1b[4A") # 上移 4 行到第一个 FileRead + line(tool_call("FileRead", "path=a.cpp", dot=DOT_SUCCESS)) + sys.stdout.write("\x1b[3B") # 回到下面 + time.sleep(0.4) + print() + + print(BOLD + "[3] 参数预览与 task_complete" + RESET) + line(tool_call("TaskComplete", "summary=\"重构完成\"")) + time.sleep(0.3) + line(tool_result("● Done for 4.2s", ok=True)) + print() + + print(BOLD + "对比其他项目:" + RESET) + print(f" opencode: 消息卡片左侧 {fg(180,140,220)}┃{RESET} 竖线,工具是文本 part,无三态灯") + print(f" pi: toolPendingBg/SuccessBg/ErrorBg {fg(60,60,80)}背景色块{RESET},无指示灯") + print(f" grok: {fg(180,140,220)}┃{RESET} accent 竖线 + block 折叠/截断,无 FIFO 配对语义") + print() + print(BOLD + "acecode 这套是自创的紧凑 transcript 风格,信息密度高:" + RESET) + print(" - 指示灯三态一眼看出工具成败") + print(" - FIFO 配对让并行调用的「谁还没完成」一目了然") + print(" - Ctrl+O 全局展开 / Ctrl+E 逐行展开长输出") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/09_streaming_markdown.py b/docs/tui-comparison/demos/09_streaming_markdown.py new file mode 100644 index 00000000..8a897c0b --- /dev/null +++ b/docs/tui-comparison/demos/09_streaming_markdown.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""09 - 流式 markdown 增量渲染基准 + +报告章节:3.2 +acecode 现状:❌ 整条重渲染 + redraw_pacer 限帧,无增量、无缓存层 +有此能力: + crush -- glamour "stable-prefix":缓存安全前缀,只渲染尾部 + grok-build -- StreamingMarkdownRenderer checkpoint 冻结,只渲染活跃 tail + opencode -- OpenTUI streaming={true} 增量解析 + pi -- 整条重解析但 cachedText/cachedWidth 缓存 + +本演示模拟流式逐 chunk 追加一个 markdown 文档,对比三种"渲染"策略的耗时: + [朴素] 每次整条重新"渲染"(逐字符高亮扫描 + 行折叠) -- 对应 acecode + [前缀缓存] 已稳定的块渲染一次后缓存,只渲染活跃尾块 -- 对应 crush + [checkpoint] 同上,块粒度冻结,只渲染活跃尾块 -- 对应 grok + +关键:渲染开销做成 O(块长度)(逐字符状态机扫描,模拟 tokenizer+高亮+Element 构造), +缓存方法用块计数 O(1) 跳过已冻结块,而非 O(n) 字符串比较。这样朴素法的 O(n²) 累计 +才会显出来。 +""" + +import sys +import time +from _term import init, RESET, BOLD, DIM, fg + +init() + +WIDTH = 80 + +# 模拟一个会不断增长的 markdown 文档(段落与代码块交替,每段较长) +PARA_TEXT = ( + "这是一段模拟的 markdown 段落,描述某次工具调用的结果与中间推理过程。" + "包含若干行文字与代码引用,用于测量「渲染」工作量。流式场景下会逐 chunk 追加," + "每段约两百字,渲染时需逐字符做词法扫描与高亮状态机推进,再折行输出。" +) +CODE_TEXT = """```python +def render(doc: str, width: int) -> list[str]: + out = [] + for raw in doc.split("\\n"): + out.extend(wrap(raw, width)) + return out +```""" + + +def build_doc(n_chunks): + """构造 n_chunks 段的文档(段落与代码块交替,\\n\\n 分块)。""" + parts = [] + for i in range(n_chunks): + parts.append(f"### 第 {i+1} 段\n\n{PARA_TEXT}\n") + if i % 3 == 0: + parts.append(CODE_TEXT + "\n") + return "\n\n".join(parts) + + +# ---- "渲染"模拟:逐字符高亮扫描(状态机)+ 行折叠 ---- + +def render_block(block_text): + """渲染单个块:逐字符推进高亮状态机 + 折行。O(块长度)。""" + out = [] + for line in block_text.split("\n"): + # 逐字符词法扫描(模拟 tokenizer + 高亮,这是主要开销) + state = 0 + for ch in line: + state = (state * 131 + ord(ch)) & 0x7FFFFFFF + # 折行 + if not line: + out.append("") + continue + tokens = line.replace("`", " ` ").split() + cur = "" + for tok in tokens: + if len(cur) + len(tok) + 1 > WIDTH: + out.append(cur) + cur = tok + else: + cur = (cur + " " + tok) if cur else tok + if cur: + out.append(cur) + return out + + +# ---- 三种策略 ---- + +class NaiveRenderer: + """acecode 现状:整条重渲染。每次都渲染所有块。""" + + def render(self, text): + blocks = text.split("\n\n") + out = [] + for b in blocks: + out.extend(render_block(b)) + out.append("") # 块间空行 + return out + + +class StablePrefixRenderer: + """crush stable-prefix:已稳定的块渲染一次缓存,只渲染活跃尾块。 + + 文本只增不减,所以"除最后一块外"都是稳定块。用块计数跟踪,避免 O(n) 字符串比较。 + """ + + def __init__(self): + self.frozen_lines = [] # 已缓存的前缀渲染行 + self.frozen_block_count = 0 # 已冻结的块数 + + def render(self, text): + blocks = text.split("\n\n") + n = len(blocks) + out = list(self.frozen_lines) + # 新稳定化的块:之前是"活跃尾块",现在变成稳定块,渲染并冻结 + while self.frozen_block_count < n - 1: + idx = self.frozen_block_count + self.frozen_lines.extend(render_block(blocks[idx])) + self.frozen_lines.append("") + self.frozen_block_count += 1 + # 只渲染活跃尾块(最后一块) + if n > 0: + out = list(self.frozen_lines) + out.extend(render_block(blocks[-1])) + out.append("") + return out + + +class CheckpointRenderer: + """grok checkpoint:块粒度冻结,语义同 StablePrefix(本模拟中块即 checkpoint)。""" + + def __init__(self): + self.frozen_lines = [] + self.frozen_block_count = 0 + + def render(self, text): + blocks = text.split("\n\n") + n = len(blocks) + while self.frozen_block_count < n - 1: + idx = self.frozen_block_count + self.frozen_lines.extend(render_block(blocks[idx])) + self.frozen_lines.append("") + self.frozen_block_count += 1 + out = list(self.frozen_lines) + if n > 0: + out.extend(render_block(blocks[-1])) + out.append("") + return out + + +def benchmark(): + print(BOLD + "=== 流式 markdown 增量渲染基准 ===" + RESET) + print(DIM + "模拟逐 chunk 追加文档,测量每次「渲染」的耗时(微秒)" + RESET) + print(DIM + "渲染开销 = 逐字符高亮状态机扫描 + 行折叠(O(块长度))" + RESET) + print() + print(f" {'chunk':<7} {'朴素(acecode)':<20} {'前缀缓存(crush)':<20} {'checkpoint(grok)':<20} {'朴素/缓存'}") + print(f" {'-'*7} {'-'*20} {'-'*20} {'-'*20} {'-'*10}") + + naive = NaiveRenderer() + prefix = StablePrefixRenderer() + checkpoint = CheckpointRenderer() + + total_naive = total_prefix = total_ckpt = 0 + n_chunks = 60 + + for i in range(1, n_chunks + 1): + doc = build_doc(i) + + t0 = time.perf_counter_ns() + naive.render(doc) + t_naive = time.perf_counter_ns() - t0 + + t0 = time.perf_counter_ns() + prefix.render(doc) + t_prefix = time.perf_counter_ns() - t0 + + t0 = time.perf_counter_ns() + checkpoint.render(doc) + t_ckpt = time.perf_counter_ns() - t0 + + total_naive += t_naive + total_prefix += t_prefix + total_ckpt += t_ckpt + + if i % 10 == 0 or i == 1: + ratio = t_naive / max(t_prefix, 1) + print(f" {i:<7} {t_naive/1000:>12.1f} µs {t_prefix/1000:>12.1f} µs " + f"{t_ckpt/1000:>12.1f} µs {ratio:>6.1f}x") + + print() + print(BOLD + "累计耗时(60 次流式更新):" + RESET) + print(f" 朴素(acecode) : {total_naive/1e6:>8.2f} ms") + print(f" 前缀缓存(crush) : {total_prefix/1e6:>8.2f} ms " + f"({total_naive/max(total_prefix,1):.1f}x 加速)") + print(f" checkpoint(grok) : {total_ckpt/1e6:>8.2f} ms " + f"({total_naive/max(total_ckpt,1):.1f}x 加速)") + print() + print(BOLD + "结论:" + RESET) + print(" 朴素法每次渲染全部块,文档越长单次开销越大,累计 O(n²)。") + print(" 缓存法只渲染活跃尾块(O(1) 块/次),稳定块渲染一次后冻结,累计 O(n)。") + print(" → acecode 的 format_markdown 每次流式更新整条重渲染 + 重建 FTXUI Element,") + print(" 长输出时 CPU 开销随文档长度线性增长,这是最值得补的差距(抄 crush/grok)。") + print() + print(DIM + "注:绝对数值随机器/Python 版本变化,重点是朴素法随 chunk 增长的斜率" + RESET) + print(DIM + " vs 缓存法的平坦斜率。实际 acecode 的 Element 构造开销更高,差距更大。" + RESET) + + +if __name__ == "__main__": + benchmark() diff --git a/docs/tui-comparison/demos/10_gradient_text.py b/docs/tui-comparison/demos/10_gradient_text.py new file mode 100644 index 00000000..d165457b --- /dev/null +++ b/docs/tui-comparison/demos/10_gradient_text.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""10 - kimi-code 渐变品牌字(gradientText) + +报告章节:3.4 +acecode 现状:❌ 无渐变,FTXUI 每 cell 单色 +有此能力:kimi-code -- theme/gradient-text.ts 的 gradientText(): + 逐字符在 fromHex→toHex 间插值,给每个字符单独 ANSI truecolor, + 配合 accentBias 让渐变在首尾之间偏折。 + +演示: + [A] Kimi 品牌风渐变(banner/logo 用) + [B] 多组渐变对比(不同起止色) + [C] accentBias 效果(渐变聚集在左侧) + 对照 acecode 的单色加粗文本(FTXUI 无法逐字符渐变) + +实现:gradientText 的做法与 ANSI truecolor 完全兼容(逐字符 38;2;r;g;b), +FTXUI 之所以做不到,是因为它的 Element 按"整段文本"上色,不暴露逐字符色。 +""" + +import sys +from _term import init, RESET, BOLD, DIM + +init() + + +def lerp(a, b, t): + return round(a + (b - a) * t) + + +def hex_to_rgb(h): + h = h.lstrip('#') + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def gradient_text(text, from_hex, to_hex, accent_bias=1.0): + """复刻 kimi gradientText:逐字符插值 + accentBias 偏折。""" + chars = list(text) + n = len(chars) + if n <= 1: + return text + c1 = hex_to_rgb(from_hex) + c2 = hex_to_rgb(to_hex) + out = [] + for i, ch in enumerate(chars): + ratio = min(1.0, (i / (n - 1)) * accent_bias) + r, g, b = lerp(c1[0], c2[0], ratio), lerp(c1[1], c2[1], ratio), lerp(c1[2], c2[2], ratio) + out.append(f"\x1b[1m\x1b[38;2;{r};{g};{b}m{ch}") + return "".join(out) + RESET + + +def main(): + print(BOLD + "=== kimi-code 渐变品牌字(gradientText)===" + RESET) + print(DIM + "逐字符在 from→to 间插值 truecolor;kimi 用于 banner / 品牌元素" + RESET) + print() + + print(BOLD + "[A] Kimi 品牌风渐变:" + RESET) + print(" " + gradient_text("✦ Kimi CLI ✦", "#1a8fff", "#7c3aed")) + print(" " + gradient_text(" moonshot-ai / kimi-code ", "#ff5a5f", "#ffb347")) + print() + + print(BOLD + "[B] 多组渐变对比:" + RESET) + pairs = [ + ("Kimi 蓝→紫", "#1a8fff", "#7c3aed"), + ("青→绿", "#00d2ff", "#3a7bd5"), + ("粉→橙", "#f857a6", "#ff5858"), + ("绿→青", "#00b09b", "#96c93d"), + ] + for label, a, b in pairs: + print(f" {label:<12} {gradient_text('██████ gradient text ██████', a, b)}") + print() + + print(BOLD + "[C] accentBias 偏折(渐变聚集在左侧):" + RESET) + print(" bias=1.0 " + gradient_text("ACECode TUI Discovery", "#ff0000", "#0000ff", 1.0)) + print(" bias=0.4 " + gradient_text("ACECode TUI Discovery", "#ff0000", "#0000ff", 0.4)) + print() + + print(BOLD + "对照 acecode(FTXUI 单色加粗):" + RESET) + print(" " + f"\x1b[1m\x1b[38;2;90;140;255m✦ AceCode CLI ✦{RESET}") + print(DIM + " ↑ FTXUI Element 按整段文本上色,不暴露逐字符色,做不到渐变。" + RESET) + print() + print(BOLD + "实现原理:" + RESET) + print(" kimi 的 gradientText 用 chalk.hex().bold() 逐字符输出,ANSI truecolor 天然支持。") + print(" FTXUI 若要做,需在 markdown_formatter 输出 Element 之前把文本按字符拆成多个") + print(" text() 元素(每个带自己的颜色),或用自绘文本路径绕过 Element —— 类似 OSC 8 的坑。") + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/demos/README.md b/docs/tui-comparison/demos/README.md new file mode 100644 index 00000000..21d590ff --- /dev/null +++ b/docs/tui-comparison/demos/README.md @@ -0,0 +1,86 @@ +# 终端界面对比演示 + +配合 [`../report.md`](../report.md) 的可运行演示。每个脚本演示一个终端效果,并标注"哪些产品有、我们现状如何"。**所有脚本都尽量用大白话描述,直接跑就能看出效果差异。** + +## 运行环境 + +- Python 3.8+(Windows 自带 3.12 可用) +- 推荐终端:**Windows Terminal** / kitty / WezTerm / iTerm2 / Ghostty +- 老版 CMD / 老式控制台:部分效果(彩色、特殊符号)会变丑,属正常降级 + +## 怎么跑 + +```bash +cd docs/tui-comparison/demos +python 01_synchronized_output.py # 同步刷新 vs 闪烁对比 +python 02_osc8_hyperlinks.py # 可点击链接 +python 03_kitty_keyboard.py # 键盘增强协议(交互,按 q 退出) +python 04_alpha_transparency.py # 半透明 vs 不透明 +python 05_animated_background.py # 动态呼吸背景(动画,Ctrl+C 退出) +python 06_spinner_showcase.py # 各家加载动画同屏(动画,Ctrl+C 退出) +python 07_osc133_prompts.py # 回合分界标记 +python 08_tool_row_dots.py # 我们的 ● 三态指示灯(招牌) +python 09_streaming_markdown.py # 流式增量排版 vs 整篇重排 +python 10_gradient_text.py # Kimi 渐变品牌字 +``` + +或一键批量跑非交互的(01/02/04/07/08/09/10): + +```bash +python run_static.py +``` + +## 每个演示讲什么 + +| 演示 | 演示的效果 | 谁有 | 我们现状 | +|---|---|---|---| +| `01_synchronized_output` | 同步刷新让画面一次到位不闪;不刷新的会闪 | pi / kimi / grok / codex | ❌ 没有 | +| `02_osc8_hyperlinks` | 可点击的链接(点一下打开) | opencode / pi / grok / codex | ⚠️ 写了检测但点不了 | +| `03_kitty_keyboard` | 键盘增强:分得清 Shift+Enter 和 Enter | opencode / pi / grok / codex | ❌ 没启用 | +| `04_alpha_transparency` | 半透明遮罩能透出底下文字 | opencode / grok / codex | ❌ 只能全盖住 | +| `05_animated_background` | 会"呼吸"的动态背景 | opencode / grok | ❌ 没有 | +| `06_spinner_showcase` | 各家加载动画同屏对比 | 各家都有好看的 | ❌ 仅静态 ● | +| `07_osc133_prompts` | 给每轮对话打"回合标记",支持跳转 | pi / kimi | ❌ 没有 | +| `08_tool_row_dots` | 工具调用行 ● 灰/绿/红三态灯 | 我们的招牌 | ✅ 独有 | +| `09_streaming_markdown` | 流式输出:增量排版 vs 整篇重排 | crush / grok / codex | ❌ 整篇重排 | +| `10_gradient_text` | 逐字变色的渐变字 | kimi-code | ❌ 单色 | + +## 各演示一句话说明 + +### 01_synchronized_output.py +**同步刷新**:屏幕刷新时如果上半新下半旧,看着就闪。加了同步刷新,画面整块一次到位。先看"不加同步"的闪烁,再看"加了同步"的平滑。pi / kimi / grok / codex 都有,我们没有。 + +### 02_osc8_hyperlinks.py +**可点击链接**:终端里 Ctrl/Cmd+点击链接直接打开。我们其实写了"检测终端支持不支持"的代码,但因为底层框架限制,**链接最终只显示成带下划线的纯文字,点不了**。本演示对比"可点"和"我们现在的样子"。 + +### 03_kitty_keyboard.py +**键盘增强协议**:终端默认分不清 Shift+Enter 和 Enter(都当回车)。启用增强后能区分。本演示先看键码对照表,再进入实时捕获,按 q 退出。**注意:Windows Terminal 目前不支持这个协议**,在它上面只能看到"分不清"的效果——这本身就是我们现状的写照。 + +### 04_alpha_transparency.py +**半透明遮罩**:opencode/codex 能画"半透明"的对话框,底下文字隐约可见,有层次感。终端本身不支持半透明,它们是先把颜色算好再画(模拟效果)。我们只能全盖住或全透明。演示里对比"不透明(我们)"和"半透明(别人)",还有一个透明度呼吸动画。 + +### 05_animated_background.py +**动态背景**:像呼吸一样明暗起伏的背景,opencode 的"呼吸光圈"和 grok 的"波浪"风格。我们现在没有背景动画。Ctrl+C 退出。 + +### 06_spinner_showcase.py +**加载动画同屏**:把各家的加载动画摆一起转: +- 我们:静态 ●(只会亮暗变化) +- grok:盲文转圈 / 圆点 / 呼吸圆 +- opencode:霓虹扫描光条(像跑马灯) +Ctrl+C 退出。 + +### 07_osc133_prompts.py +**回合分界标记**:给每一轮对话的起止打上隐形标记,支持的终端(kitty/WezTerm)可以一键在回合之间跳转,不用手动滚。我们现在没有,只能逐行滚。 + +### 08_tool_row_dots.py +**我们的招牌**:工具调用行显示成 `● ToolName(args)`,灯色代表执行状态(灰=跑 / 绿=成功 / 红=失败),结果缩进一行。还演示了"并行工具谁先完成"的配对逻辑。别人家都是纯文字或色块,这套三态灯是我们独有的。 + +### 09_streaming_markdown.py +**流式增量排版基准**:模拟 AI 一句一句往外冒字,对比三种做法的耗时: +- 整篇重排(我们现在):每冒一句把整篇重算,越长越慢 +- 增量排版(crush):只算新冒出来的尾巴 +- 冻结法(grok/codex):已稳定的段落冻结,只算在变的尾部 +跑完能看到整篇重排的耗时随长度直线上升,增量方案基本不变。 + +### 10_gradient_text.py +**Kimi 渐变品牌字**:像品牌 Logo 那样从蓝渐变到紫的文字。Kimi 用在欢迎页和品牌元素上。我们只能整段一个颜色。演示展示了多组渐变和"渐变聚集在左/均匀分布"两种效果。 diff --git a/docs/tui-comparison/demos/_term.py b/docs/tui-comparison/demos/_term.py new file mode 100644 index 00000000..3f312e6a --- /dev/null +++ b/docs/tui-comparison/demos/_term.py @@ -0,0 +1,188 @@ +"""Shared terminal helpers for TUI comparison demos. + +跨平台 ANSI 启用 + 原始输入模式(交互演示用)。 +所有演示 import 本模块以获得统一的颜色/光标/协议常量与清理逻辑。 +""" + +import os +import sys +import ctypes +import contextlib + + +# --------------------------------------------------------------------------- +# Windows: 启用 VT 处理 + UTF-8 输出 +# --------------------------------------------------------------------------- + +def enable_vt(): + """Windows 上开启 ENABLE_VIRTUAL_TERMINAL_PROCESSING + UTF-8 输出 CP。 + + 不开启时,ANSI 转义序列会被原样打印(看到一堆 \\x1b[...),且盲文/box-drawing + 字符在非 UTF-8 代码页下会乱码。grok-build 的 configure_windows_console() 做同样的事。 + """ + if os.name != 'nt': + return + try: + kernel32 = ctypes.windll.kernel32 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + # STD_OUTPUT_HANDLE = -11, STD_ERROR_HANDLE = -12 + for handle in (-11, -12): + h = kernel32.GetStdHandle(handle) + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(h, ctypes.byref(mode)): + kernel32.SetConsoleMode(h, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + kernel32.SetConsoleOutputCP(65001) # CP_UTF8 + except Exception: + pass + + +# --------------------------------------------------------------------------- +# ANSI / 终端协议常量 +# --------------------------------------------------------------------------- + +RESET = "\x1b[0m" +BOLD = "\x1b[1m" +DIM = "\x1b[2m" +ITALIC = "\x1b[3m" +UNDERLINE = "\x1b[4m" + +CLEAR = "\x1b[2J\x1b[H" +CLEAR_LINE = "\x1b[2K" + +HIDE_CURSOR = "\x1b[?25l" +SHOW_CURSOR = "\x1b[?25h" + +ALT_SCREEN = "\x1b[?1049h" +MAIN_SCREEN = "\x1b[?1049l" + +# CSI 2026 同步输出(Begin/End Synchronized Update) -- pi / grok 用 +SYNC_BEGIN = "\x1b[?2026h" +SYNC_END = "\x1b[?2026l" + +# kitty keyboard 协议 -- push flags 并设置(Ps=flags); pop 恢复 +# flags: 1=disambiguate, 2=event-type, 4=report-all-keys, 8=alternate-keys +KITTY_PUSH = "\x1b[>15u" # 1|2|4|8 +KITTY_POP = "\x1b[ str: + import base64 + return f"\x1b]52;c;{base64.b64encode(text.encode('utf-8')).decode()}\x07" + + +# --------------------------------------------------------------------------- +# 颜色辅助(truecolor) +# --------------------------------------------------------------------------- + +def fg(r, g, b): + return f"\x1b[38;2;{r};{g};{b}m" + +def bg(r, g, b): + return f"\x1b[48;2;{r};{g};{b}m" + +def fg256(n): + return f"\x1b[38;5;{n}m" + +def bg256(n): + return f"\x1b[48;5;{n}m" + +def goto(row, col): + return f"\x1b[{row};{col}H" + +def up(n=1): + return f"\x1b[{n}A" + +def down(n=1): + return f"\x1b[{n}B" + + +def blend(c1, c2, t): + """线性混色(模拟 alpha 合成):t=0 全 c1,t=1 全 c2。 + + opencode/grok 的 RGBA 透明在 framebuffer 层做 per-pixel alpha 合成; + ANSI 无法逐 cell 设 alpha,这里用预混色模拟视觉效果(acecode 的 FTXUI 做不到)。 + """ + return tuple(round(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)) + + +# --------------------------------------------------------------------------- +# 原始输入模式(交互演示用,跨平台) +# --------------------------------------------------------------------------- + +@contextlib.contextmanager +def raw_mode(): + """进入原始输入模式(cbreak),退出时恢复。 + + Windows: SetConsoleMode 清除 ECHO/LINE_INPUT/PROCESSED_INPUT, + 开启 ENABLE_VIRTUAL_TERMINAL_INPUT(让修饰键组合以 VT 序列上报)。 + 这正是 pi 的 win32-console-mode.node 做的事。 + POSIX: termios tty.setcbreak。 + """ + if os.name == 'nt': + kernel32 = ctypes.windll.kernel32 + STD_INPUT_HANDLE = -10 + ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + ENABLE_ECHO_INPUT = 0x0004 + ENABLE_LINE_INPUT = 0x0002 + ENABLE_PROCESSED_INPUT = 0x0001 + h = kernel32.GetStdHandle(STD_INPUT_HANDLE) + old = ctypes.c_uint32() + kernel32.GetConsoleMode(h, ctypes.byref(old)) + new = old.value & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT) + new |= ENABLE_VIRTUAL_TERMINAL_INPUT + kernel32.SetConsoleMode(h, new) + try: + yield + finally: + kernel32.SetConsoleMode(h, old.value) + sys.stdout.write(RESET + SHOW_CURSOR) + sys.stdout.flush() + else: + import termios + import tty + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + sys.stdout.write(RESET + SHOW_CURSOR) + sys.stdout.flush() + + +def read_key(): + """读一个按键(可能多字节),返回原始字节字符串。非阻塞超时返回 b''。""" + import select + ch = sys.stdin.buffer.read(1) + if not ch: + return b'' + # 读余下的转义序列(非阻塞,短超时) + seq = ch + while True: + r, _, _ = select.select([sys.stdin], [], [], 0.02) + if not r: + break + c = sys.stdin.buffer.read(1) + if not c: + break + seq += c + return seq + + +# --------------------------------------------------------------------------- +# 公共入口:每个 demo 调用一次 +# --------------------------------------------------------------------------- + +def init(): + enable_vt() + # 确保 stdout 用 UTF-8(Windows 上 sys.stdout 可能是 cp936) + if os.name == 'nt': + try: + sys.stdout.reconfigure(encoding='utf-8') + except Exception: + pass diff --git a/docs/tui-comparison/demos/run_static.py b/docs/tui-comparison/demos/run_static.py new file mode 100644 index 00000000..6b985a10 --- /dev/null +++ b/docs/tui-comparison/demos/run_static.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""批量运行非交互演示(01/02/04/07/08/09)。 + +动画类(05/06)和交互类(03)需单独跑。每个演示之间暂停按回车继续。 +""" + +import subprocess +import sys +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) + +DEMOS = [ + ("01_synchronized_output.py", "CSI 2026 同步输出对比"), + ("02_osc8_hyperlinks.py", "OSC 8 可点击超链接"), + ("04_alpha_transparency.py", "RGBA alpha 透明混色"), + ("07_osc133_prompts.py", "OSC 133 prompt 标记"), + ("08_tool_row_dots.py", "acecode ● 三态指示灯"), + ("09_streaming_markdown.py", "流式 markdown 增量渲染基准"), + ("10_gradient_text.py", "kimi 渐变品牌字"), +] + + +def main(): + for script, desc in DEMOS: + path = os.path.join(HERE, script) + print("\n" + "=" * 70) + print(f" {desc}") + print("=" * 70) + try: + subprocess.run([sys.executable, path], cwd=HERE, check=False) + except Exception as e: + print(f" [运行失败] {e}") + if script != DEMOS[-1][0]: + try: + input("\n按回车继续下一个演示(Ctrl+C 中止)...") + except (KeyboardInterrupt, EOFError): + print() + break + + +if __name__ == "__main__": + main() diff --git a/docs/tui-comparison/report.md b/docs/tui-comparison/report.md new file mode 100644 index 00000000..30881d4e --- /dev/null +++ b/docs/tui-comparison/report.md @@ -0,0 +1,229 @@ +# ACECode 终端界面 与同类产品对比(产品视角) + +> 版本:2026-08-15(含 kimi-code / codex) +> 一句话:我们和市面上 6 款主流"AI 编程终端工具"对比了终端界面的实现方式和实际观感,搞清楚:别人家终端界面"好看、流畅、好用"是怎么做到的,我们差在哪,补什么最划算。 + +对比对象(D:\dev 下的 7 个项目,其中 `agent`/`openchamber`/`pi-web` 没有终端界面,不参与): + +| 产品 | 用什么技术做的界面 | 一句话印象 | +|---|---|---| +| **ACECode**(本项目) | C++ 自研界面框架(FTXUI) | 老牌 Windows 终端兼容最好,工具调用行设计独树一帜 | +| **opencode** | 自己的终端 UI 引擎(OpenTUI) | 界面最"华丽":有动画背景、渐变、霓虹加载条 | +| **pi** | 自研界面库(pi-tui) | 工程最扎实:整屏一次刷新不闪、可点链接、可贴图 | +| **kimi-code** | 直接用了 pi 的界面库(魔改版) | 和 pi 一个底子,但外壳很有品牌感:渐变字、月亮加载动画、多代理协作 | +| **crush** | 成熟的 Go 界面框架(Bubble Tea) | 中庸稳妥,增量排版省性能 | +| **grok-build** | Rust 界面框架(ratatui)+ 深度魔改 | 特效最多:波浪动画、内嵌图片/视频、甚至内置小游戏 | +| **codex** | Rust 界面框架(ratatui)+ 深度魔改 | 思路最先进:历史直接滚进终端,界面只留一小块,最流畅 | + +血缘关系:7 个产品实际只有 **5 套独立的技术路线** —— pi 和 kimi-code 是同一套壳,crush 自成一家,opencode 一家,ratatui 系(grok / codex)两家各自魔改。 + +--- + +## 一、各家怎么把画面画出来(性能差异的根源) + +**先理解两件事:** +1. 终端本身是个"文本画布",软件每刷新一次,就是往画布上写字。怎么"写"决定了流畅度和闪不闪。 +2. 有两个方案:① 把整个画面从头重画一遍;② 只把**有变化的部分**重画,其余不动。 + +| 产品 | 重画方式 | 会不会闪 | 说明 | +|---|---|---|---| +| **ACECode** | 每帧把界面全量重画 + 光标上移重绘 | 老终端上会闪 | 全量重画,越长的对话越吃力 | +| **opencode** | 内存里先画好整张图,只把变化的格子同步上屏 | 好 | 只更新变化部分,天生省 | +| **pi / kimi-code** | 每次只重画有变化的"行" | 好 | 按行对比新旧画面,只改变了的行 | +| **crush** | 每次把整屏文字重新拼一遍交给框架去刷新 | 一般 | 框架内部会省,但拼整屏本身费劲 | +| **grok-build** | 内存双画布逐格对比,只重画变化的格子 | 好 | 画面变化小的时候开销极小 | +| **codex** | 和 grok 类似,但更激进(见下) | 最好 | 见"codex 的思路" | + +**一个影响观感的关键技术——"同步刷新"(专业名 CSI 2026):** +终端刷新是有"半帧"状态的(上半屏新的、下半屏旧的),看着就闪。加了同步刷新,画面会**整块一次到位**,完全不闪。这是很多产品"看起来高级"的一大原因。 +- ✅ 有同步刷新:pi / kimi-code / grok-build / codex +- ❌ 没有:ACECode(目前是光标上移逐行重绘,老终端上会闪)、crush(依赖框架) + +**codex 的思路(最值得学的一招):** +别的产品(包括我们)都是"整个界面自己做,内容都在自己的画布里"。codex 反着来:**已经说完的话直接写进终端的历史区**(就是你往上滚鼠标能看到的那一片),界面只占屏幕底部一小块"当前状态区"。好处: +- 每帧要重画的东西极少 → 特别流畅 +- 历史天然能往上翻,不用自己做滚动 +- 唯一的代价:窗口拉宽时需要把历史按新宽度重排一遍(codex 做了,还按终端型号设了重排行数上限,防止卡死) + +> 对我们:ACECode 其实已经有"滚屏模式"(内容进历史区)和"全屏模式"两种,方向和 codex 一致,只是实现还停留在"光标上移重绘",没做到"写完就永久落定、界面只留当前块"。这是最清晰的一条升级路线。 + +--- + +## 二、AI 说话(流式输出)时,各家表现如何 + +AI 打字是一点一点冒出来的。怎么把"正在冒的字"画得又顺又不晃,是体验差异的大头。 + +| 产品 | 流式表现 | 说明 | +|---|---|---| +| **ACECode** | 每冒一段就把**整篇**重新排版渲染 | 对话越长越卡,长文输出时明显 | +| **crush** | 只重排"新冒出来的尾巴",前面排版好的直接复用 | 前面部分不会再算,省很多 | +| **grok-build** | 更聪明:已经稳定的段落"冻结",只渲染还在变化的尾部 | 长文档也流畅 | +| **opencode** | 流式"增量排版" + 把 16ms 内的多次更新合并成一次画 | 很顺 | +| **pi / kimi-code** | 每次整篇重排但内部有缓存,还做了"代码块没闭合时不提前画"的防抖 | 平滑但费 CPU | +| **codex** | 最成熟:已说完的逐行"打字机式"滚进历史区,新内容在底部显示;**表格会整张憋住,等表格排完再一次性显示**(否则加一行整表就抖一下) | 观感最稳 | + +**一句话:流式输出这块我们目前是全场最吃力的一档**,对话一长,每次 AI 冒几个字都要把整篇重新算一遍。crush / codex 的做法(只重排新增的尾巴、冻结已稳定部分)是明确可抄的。 + +> 想直观看到差距,跑 `demos/09_streaming_markdown.py`:同样的内容,朴素整篇重排的耗时随长度线性上涨,增量方案基本持平。 + +--- + +## 三、代码块配色(谁的颜色漂亮且不依赖网络) + +| 产品 | 代码着色方案 | 特点 | +|---|---|---| +| **ACECode** | 内置语言表 | 离线可用,语言数量固定 | +| **opencode** | 运行时从网上下载语法包(30+ 语言) | 颜色最全,但**断网就退步**,且要等下载 | +| **pi / kimi-code** | highlight.js | 离线,语言够用 | +| **grok-build** | syntect 引擎 + 内置配色,自动适配终端色阶 | 离线 | +| **codex** | 覆盖 **250+ 语言** 的引擎 + 32 套内置配色 + 用户自定义 | 离线,覆盖最全 | + +**说明:** 这里只有 opencode 有"联网依赖",其余都是离线的。codex 的覆盖面最广(250+ 语言),但这对普通使用差异不大——我们内置的语言表日常够用。 + +--- + +## 四、界面特效与"高级感"(我们目前最朴素) + +| 效果 | 我们 | opencode | pi | kimi-code | grok | codex | +|---|---|---|---|---|---|---| +| 动态背景(会呼吸的光圈/波浪) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | +| 半透明叠层(遮罩能透出底下) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | +| **渐变品牌字** | ❌ | ❌ | ❌ | ✅(Kimi 紫蓝渐变) | ❌ | ❌ | +| 好看的加载动画 | 仅静态 ● | ✅ 霓虹扫描光条 | ✅ 转圈 | ✅ **月亮月相 + 转圈** | ✅ 多种 | ✅ 转圈 | +| 内嵌图片 | ❌ | ❌ | ✅ | ✅(含缩略图) | ✅ | ✅(ASCII 宠物) | +| 内嵌视频 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| 图表(流程图等)直接画在终端 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| 工具调用行"● 三态指示灯" | ✅ **我们的招牌** | 纯文字 | 底色块 | 底色块 | 竖线 | 圆点 | + +**小结:** +- **opencode / grok 是"特效派"**(动画背景、霓虹加载、内嵌媒体),**codex / pi 是"流畅稳派"**,kimi-code 在 pi 的底子上加了很强的品牌包装(渐变字、月亮动画、欢迎页)。 +- 我们目前是**最朴素的一档**,但有一个别人没有的招牌:**工具调用行的"● 三态指示灯"**(灰=正在跑 / 绿=成功 / 红=失败,一眼看清工具执行状态),这是我们的差异化亮点,值得保留并强化。 + +> 想亲眼看:kimi 的渐变字 `demos/10_gradient_text.py`;半透明 vs 不透明 `demos/04_alpha_transparency.py`;动态背景 `demos/05_animated_background.py`;各家加载动画同屏 `demos/06_spinner_showcase.py`;我们的 ● 指示灯 `demos/08_tool_row_dots.py`。 + +--- + +## 五、输入框体验(打字、粘贴、快捷键) + +| 产品 | 输入框能力 | +|---|---| +| **ACECode** | 基础:历史记录、路径补全、粘贴处理、中文输入法兼容。**没有**输入框内高亮、没有虚拟占位、没有组合快捷键 | +| **opencode** | 最强:输入框里直接**语法高亮**、可以插入"图片占位符"这种虚拟元素、支持复杂组合快捷键(如两键和弦)、粘贴智能压缩 | +| **pi** | 强:撤销栈、剪贴板环、自动补全、长粘贴自动折叠成"[已粘贴 N 行]"、对中文/日文断行友好 | +| **kimi-code** | 同 pi + 文件 `@` 补全 | +| **grok-build** | 极客双模式(Vim/普通),圆角输入框 | +| **codex** | 最强之一:自带 Vim 模式、剪贴板删除缓冲、粘贴压缩、**配置驱动的完整快捷键体系**(支持组合键) | + +**一句话:输入框这块 opencode / codex 最接近"编辑器级"体验,我们目前是基础款。** 对产品经理来说,最值得抄的两个点:① 长粘贴自动折叠(贴 500 行不用闪屏);② 复杂快捷键(leader + 组合键)。 + +--- + +## 六、键盘与终端"认不认识"你按的键(细节但影响大) + +终端有个老毛病:它分不清"你按了 Enter"还是"Shift+Enter"(都报成同一个信号)。键盘增强协议(专业名 kitty keyboard / CSI-u)能解决——按 Shift+Enter 就能识别出"带 Shift"。 + +| 产品 | 是否启用键盘增强 | 程度 | +|---|---|---| +| **ACECode** | ❌ 没启用 | 只消费通用的方向键修饰信号,Shift+Enter 这种拿不到 | +| **opencode / pi / kimi-code / grok** | ✅ | 通用启用 | +| **codex** | ✅ | **最精细**:针对不同终端(苹果终端/kitty/Ghostty/Windows 版 VS Code)分别调优,还会先探测你的终端再决定用哪种 | + +另外,codex 会在启动时用 0.1 秒快速"摸一下"你的终端是什么型号、支持什么颜色、什么键盘能力,然后自动适配(比如界面主题颜色跟着你的终端背景自动变)。**这是一个低成本高回报的点:让界面自动适配终端,而不是写死。** + +--- + +## 七、Windows / 老终端兼容(我们的强项) + +| 产品 | 对老 Windows 终端的态度 | +|---|---| +| **ACECode** | ✅ **最扎实**:老式 CMD/ConEmu 上自动退化成纯 ASCII 边框、去掉大 Logo,保证还能看 | +| **grok-build** | 很彻底:强制把终端设为 UTF-8(否则特殊符号变乱码),老终端自动换能显示的符号 | +| **codex** | 只面向现代终端(Windows Terminal),老式 CMD 基本不考虑 | +| **pi/kimi/opencode/crush** | 主要测现代终端 | + +**这是我们的护城河之一:老终端兼容性全场最好。** 其他产品大多"只在现代终端上测",我们连 2018 年之前的旧版 CMD 都做了降级处理。 + +--- + +## 八、主题 / 换肤 + +| 产品 | 换肤能力 | +|---|---| +| **ACECode** | 内置深/浅两套配色,不可自定义 | +| **opencode** | 30+ 内置主题 + 用户自定义 + **自动从你的终端取色生成主题** | +| **grok-build** | 5 套主题 + 跟随终端背景色 | +| **pi / kimi-code** | 深/浅 + 热重载(改配置立刻生效)+ kimi 支持自定义主题加载和终端背景探测 | +| **codex** | 语法配色 32 套 + 用户自定义;**界面强调色跟随你的终端背景自动调**(深底自动用浅色强调) | + +**一句话:换肤是我们的明显短板**(只有两套写死的),别人都支持用户自定义甚至自动跟随终端。**这是产品经理能直接感知的"高级感"差距。** + +--- + +## 九、长对话 / 大输出的流畅度 + +| 产品 | 手段 | +|---|---| +| **ACECode** | 限帧率 + 折叠超长输出 + 聊天区窗口化 | +| **codex** | 最先进:说完的话滚进终端历史区(见第一节),界面永远只画一小块 + 120 帧上限 + 多层缓存 | +| **grok-build** | 虚拟滚动(只画看得见的)+ 高度缓存 | +| **pi/kimi** | 每帧缓存 + 性能基准测试 | +| **opencode** | 只画可视区域 + 流式合并更新 + 代码自动折叠 | +| **crush** | 增量排版缓存 | + +**一句话:长对话的流畅度,codex 领先(靠"历史出界面"这一招),我们是中游偏下。** 我们的"折叠超长输出"思路对,但还停留在"全量重排 + 限帧"层面。 + +--- + +## 十、我们的独有优势(值得保住) + +1. **老 Windows 终端兼容全场最好**(见第七节)。 +2. **工具调用行"● 三态指示灯"**是自创招牌,信息密度高、一眼看懂执行状态。 +3. **单一安装包、零依赖、断网可用**——opencode 要联网下语法包,pi/kimi 要装 Node,我们一个二进制全搞定。 +4. **一个界面引擎同时服务终端版 / 后台服务 / 桌面壳 / 无头模式**,架构上是"一套渲染多处用",别人大多是单体。 + +--- + +## 十一、最该补的 8 个差距(按性价比排序) + +> 难度:★ 低 ~ ★★★ 高;收益:指用户能直接感知的改善。 + +| # | 差距 | 难度 | 收益 | 一句话 | +|---|---|---|---|---| +| 1 | **流式输出增量排版**(别再整篇重排) | ★★ | 高 | 长对话卡顿,最明显的短板,crush/codex 有现成方案 | +| 2 | **同步刷新防闪烁** | ★ | 中 | 一劳永逸消除老终端闪烁,改动小 | +| 3 | **可点击链接真正可用** | ★★★ | 中 | 我们其实写了检测代码,但框架限制发不出去(点不了),需要绕过框架 | +| 4 | **键盘增强协议**(分得清 Shift+Enter) | ★★ | 中 | codex 已按终端逐个调优 | +| 5 | **启动时快速探测终端能力**,自动适配主题/键盘 | ★★ | 中 | codex 0.1 秒探测,低成本高回报 | +| 6 | **换肤能力**(用户自定义 + 跟随终端背景) | ★★ | 中 | 目前只有两套写死,产品感知明显 | +| 7 | **输入框升级**(粘贴折叠、组合快捷键) | ★★★ | 中 | opencode/codex 已是编辑器级 | +| 8 | **滚屏模式升级**:历史写完就落定、界面只留当前块 | ★★ | 中 | 这是我们对齐 codex 流畅度的正路 | + +> 第 3、7 项受我们底层界面框架(FTXUI)限制较深,需要绕过框架或改造;其余几项可以直接抄成熟方案。 + +--- + +## 十二、演示(亲眼看效果) + +`docs/tui-comparison/demos/` 下有 10 个可直接运行的小脚本,每个演示一个效果并标注"谁有、我们现状如何": + +| 演示 | 效果 | 我们的现状 | +|---|---|---| +| `01_synchronized_output` | 同步刷新 vs 不刷新的闪烁对比 | ❌ 没有 | +| `02_osc8_hyperlinks` | 可点击链接 vs 我们的纯色文本 | ⚠️ 检测了但点不了 | +| `03_kitty_keyboard` | 键盘增强协议(交互,按 q 退出) | ❌ 未启用 | +| `04_alpha_transparency` | 半透明叠层 vs 不透明 | ❌ 不透明 | +| `05_animated_background` | 动态呼吸背景(动画,Ctrl+C 退出) | ❌ 没有 | +| `06_spinner_showcase` | 各家加载动画同屏对比 | ❌ 仅静态 ● | +| `07_osc133_prompts` | 回合分界标记(可跳转) | ❌ 没有 | +| `08_tool_row_dots` | **我们的 ● 三态指示灯(招牌)** | ✅ 独有 | +| `09_streaming_markdown` | 流式增量排版 vs 整篇重排耗时对比 | ❌ 整篇重排 | +| `10_gradient_text` | Kimi 渐变品牌字 | ❌ 单色 | + +运行方式(需 Python 3.8+,推荐 Windows Terminal): + +```bash +cd docs/tui-comparison/demos +python run_static.py # 批量跑非交互的(01/02/04/07/08/09/10) +python 05_animated_background.py # 动画类单独跑,Ctrl+C 退出 +python 03_kitty_keyboard.py # 交互类,按 q 退出 +``` diff --git a/scripts/macos_create_dmg.sh b/scripts/macos_create_dmg.sh index b122a71d..1ea866db 100755 --- a/scripts/macos_create_dmg.sh +++ b/scripts/macos_create_dmg.sh @@ -132,8 +132,29 @@ mkdir -p "$staging_root" "$background_root" /usr/bin/ditto "$app_path" "$staging_root/ACECode.app" /bin/ln -s /Applications "$staging_root/Applications" +background_png="$background_root/ACECode-DMG.png" +# Older macOS sips (e.g. 12.x) silently fails to rasterize SVG and writes no +# output while still returning success. Fall back to qlmanage when the expected +# PNG is missing or not a valid image so the Finder background still resolves. /usr/bin/sips -s format png "$background_path" \ - --out "$background_root/ACECode-DMG.png" >/dev/null + --out "$background_png" >/dev/null 2>&1 || true +if [[ ! -s "$background_png" ]]; then + echo "sips could not rasterize the SVG background; using qlmanage fallback" >&2 + if ! /usr/bin/qlmanage -t -s 660 -o "$background_root" "$background_path" >/dev/null 2>&1; then + echo "Could not rasterize the DMG background image." >&2 + exit 1 + fi + ql_png="$background_root/$(basename "$background_path").png" + if [[ ! -f "$ql_png" ]]; then + echo "qlmanage produced no background image." >&2 + exit 1 + fi + /bin/mv -f "$ql_png" "$background_png" +fi +if [[ ! -s "$background_png" ]]; then + echo "Missing DMG background raster: $background_png" >&2 + exit 1 +fi if [[ ! -L "$staging_root/Applications" ]] || \ [[ "$(/usr/bin/readlink "$staging_root/Applications")" != "/Applications" ]]; then diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index 7c0787bb..b175415b 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -22,6 +22,7 @@ #include "session/todo_state.hpp" #include "session/turn_timing.hpp" #include "skills/skill_activation.hpp" +#include "skills/skill_registry.hpp" #include "tool/ask_user_question_tool.hpp" #include "tool/mtime_tracker.hpp" #include "tool/tool_protocol_names.hpp" @@ -33,6 +34,7 @@ #include "headless/headless_mode.hpp" #include #include +#include #include #include #include @@ -425,6 +427,25 @@ void AgentLoop::set_cwd(const std::string& new_cwd) { git_snapshot_cache_.reset(); } +std::set AgentLoop::dormant_skill_names() const { + std::set out; + if (!skill_usage_store_ || skill_idle_days_ <= 0 || !skill_registry_) { + return out; + } + const std::int64_t now_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const std::int64_t idle_ms = static_cast(skill_idle_days_) * + 24LL * 60 * 60 * 1000; + for (const auto& meta : skill_registry_->list()) { + if (skill_usage_store_->is_dormant(meta.name, now_ms, idle_ms)) { + out.insert(meta.name); + } + } + return out; +} + ResolvedQuestionPolicy AgentLoop::resolved_question_policy() const { const bool has_cli = !loop_cfg_.question_policy_cli.empty(); const std::string& configured = @@ -954,9 +975,10 @@ std::vector AgentLoop::build_compaction_initial_context() const { tools_.is_allowed("skills_list", &tool_capability_policy_); const bool spawn_subagent_available = tools_.is_allowed("spawn_subagent", &tool_capability_policy_); + const std::set dormant_skills = dormant_skill_names(); PromptContextBlock skill_context = build_skills_index_context_prompt( skill_registry_, context_window_.load(std::memory_order_relaxed), - skill_view_available, skills_list_available); + skill_view_available, skills_list_available, &dormant_skills); if (!skill_context.content.empty()) { ChatMessage skill_system; skill_system.role = "system"; @@ -1739,6 +1761,13 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, LOG_INFO("[skills] Injected " + std::to_string(skill_expansion.injected_skill_names.size()) + " explicitly selected Skill prompt(s) for this turn"); + if (skill_usage_store_) { + const std::string now = SessionStorage::now_iso8601(); + for (const auto& name : + skill_expansion.injected_skill_names) { + skill_usage_store_->record(name, now); + } + } } } const std::string& model_user_message = skill_expansion.prompt; @@ -1887,9 +1916,10 @@ AgentLoop::ApiRequestBundle AgentLoop::build_api_request_messages() { tools_.is_allowed("skills_list", &tool_capability_policy_); const bool spawn_subagent_available = tools_.is_allowed("spawn_subagent", &tool_capability_policy_); + const std::set dormant_skills = dormant_skill_names(); PromptContextBlock skill_context_block = build_skills_index_context_prompt( skill_registry_, context_window_.load(std::memory_order_relaxed), - skill_view_available, skills_list_available); + skill_view_available, skills_list_available, &dormant_skills); const bool skill_context_changed = skill_context_block.cache_key != skill_context_cache_key_; std::string skill_context = cached_context_for_api( diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp index 8f12b4fa..2d17fa69 100644 --- a/src/agent_loop.hpp +++ b/src/agent_loop.hpp @@ -11,6 +11,7 @@ #include "session/ask_user_question_prompter.hpp" #include "config/config.hpp" #include "hooks/hook_runtime.hpp" +#include "skills/skill_usage_store.hpp" #include #include @@ -334,6 +335,11 @@ class AgentLoop { void notify_goal_objective_updated(); void set_skill_registry(const SkillRegistry* sr) { skill_registry_ = sr; } + void set_skill_usage_store(SkillUsageStore* store) { skill_usage_store_ = store; } + void set_skill_idle_days(int days) { skill_idle_days_ = days; } + // Names of skills that are dormant (idle past the threshold, not pinned). + // Returns an empty set when dormancy is disabled or the store is unset. + std::set dormant_skill_names() const; void set_memory_registry(const MemoryRegistry* mr) { memory_registry_ = mr; } void set_memory_config(const MemoryConfig* cfg) { memory_cfg_ = cfg; } void set_project_instructions_config(const ProjectInstructionsConfig* cfg) { @@ -587,6 +593,8 @@ class AgentLoop { std::vector hook_request_context_; bool stop_hook_active_ = false; const SkillRegistry* skill_registry_ = nullptr; + SkillUsageStore* skill_usage_store_ = nullptr; + int skill_idle_days_ = 30; const MemoryRegistry* memory_registry_ = nullptr; const MemoryConfig* memory_cfg_ = nullptr; const ProjectInstructionsConfig* project_instructions_cfg_ = nullptr; diff --git a/src/config/config.cpp b/src/config/config.cpp index 89039743..75763883 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -828,6 +828,9 @@ AppConfig load_config_from_path( if (sj.contains("reuse_opencode") && sj["reuse_opencode"].is_boolean()) { cfg.skills.reuse_opencode = sj["reuse_opencode"].get(); } + if (sj.contains("idle_days") && sj["idle_days"].is_number_integer()) { + cfg.skills.idle_days = sj["idle_days"].get(); + } } if (j.contains("memory") && j["memory"].is_object()) { const auto& mj = j["memory"]; @@ -1637,12 +1640,15 @@ nlohmann::json build_config_json(const AppConfig& cfg) { SkillsConfig skills_d; if (!cfg.skills.disabled.empty() || !cfg.skills.external_dirs.empty() || - cfg.skills.reuse_opencode != skills_d.reuse_opencode) { + cfg.skills.reuse_opencode != skills_d.reuse_opencode || + cfg.skills.idle_days != skills_d.idle_days) { nlohmann::json sj = nlohmann::json::object(); if (!cfg.skills.disabled.empty()) sj["disabled"] = cfg.skills.disabled; if (!cfg.skills.external_dirs.empty()) sj["external_dirs"] = cfg.skills.external_dirs; if (cfg.skills.reuse_opencode != skills_d.reuse_opencode) sj["reuse_opencode"] = cfg.skills.reuse_opencode; + if (cfg.skills.idle_days != skills_d.idle_days) + sj["idle_days"] = cfg.skills.idle_days; j["skills"] = sj; } diff --git a/src/config/config.hpp b/src/config/config.hpp index 7ef476c5..2a2b44bc 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -69,6 +69,9 @@ struct SkillsConfig { std::vector disabled; // skill names to hide even if present on disk std::vector external_dirs; // extra directories to scan (supports ~ and ${ENV}) bool reuse_opencode = true; // reuse opencode-compatible skill roots by default + // Days without use before a skill is treated as dormant (hidden from the + // automatic list). 0 disables dormancy entirely. + int idle_days = 30; // Runtime-only exact allowlist. nullopt keeps normal discovery behavior; // an engaged empty vector hides every skill. This field is intentionally // not loaded from or saved to config.json — headless mode uses it on its diff --git a/src/daemon/worker.cpp b/src/daemon/worker.cpp index e99fcb57..e4ba4603 100644 --- a/src/daemon/worker.cpp +++ b/src/daemon/worker.cpp @@ -31,6 +31,7 @@ #include "../session/session_user_message_search.hpp" #include "../skills/skill_registry.hpp" #include "../skills/skill_init.hpp" +#include "../skills/skill_usage_store.hpp" #include "../tool/ask_user_question_tool.hpp" #include "../tool/bash_tool.hpp" #include "../tool/builtin_tool_registry.hpp" @@ -579,6 +580,11 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { // 让 GET /api/skills 与 GET /api/commands 看到的 skill 集合与 TUI `/skills` 一致。 acecode::SkillRegistry skill_registry; acecode::initialize_skill_registry(skill_registry, cfg, cwd); + // Skill usage/dormancy state shared with the Web UI. Same state file the + // TUI process writes, so counts stay consistent across surfaces. Best- + // effort: read/write failures never break the daemon. + acecode::SkillUsageStore skill_usage_store( + acecode::get_acecode_dir() + "/.skill_usage_state.json"); acecode::ExpertRegistry expert_registry; acecode::ToolExecutor tools; @@ -708,6 +714,7 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { }; } web_deps.skill_registry = &skill_registry; + web_deps.skill_usage_store = &skill_usage_store; web_deps.provider = &provider; web_deps.provider_mu = &provider_mu; web_deps.dangerous = opts.dangerous; diff --git a/src/main.cpp b/src/main.cpp index 48cba35d..58fd2f42 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4637,10 +4637,15 @@ static int run_interactive_app(const InteractiveCliOptions& cli, McpManager mcp_manager; MemoryConfig runtime_memory_cfg = initialize_tui_tools_and_registries( tools, skill_registry, memory_registry, mcp_manager, config, working_dir); + // Skill usage / dormancy state shared by the TUI session and any daemon + // surface. Best-effort: a read/write failure never blocks the session. + auto skill_usage_store = std::make_shared( + get_acecode_dir() + "/.skill_usage_state.json"); TuiState state; initialize_tui_state_before_screen(state, config, working_dir, dangerous_mode, mcp_manager, provider_accessor()); + state.skill_usage_store = skill_usage_store; state.slash_command_usage_counts = read_tui_slash_command_usage(); if (!startup_worktree_banner.empty()) { state.conversation.push_back({"system", startup_worktree_banner, false}); @@ -5107,6 +5112,8 @@ static int run_interactive_app(const InteractiveCliOptions& cli, agent_loop.set_agent_loop_config(config.agent_loop); agent_loop.set_hook_manager(&hook_manager); agent_loop.set_skill_registry(&skill_registry); + agent_loop.set_skill_usage_store(skill_usage_store.get()); + agent_loop.set_skill_idle_days(config.skills.idle_days); agent_loop.set_memory_registry(&memory_registry); agent_loop.set_memory_config(&runtime_memory_cfg); agent_loop.set_project_instructions_config(&config.project_instructions); @@ -8586,6 +8593,7 @@ static int run_interactive_app(const InteractiveCliOptions& cli, &mcp_manager, &tools, &hook_manager, + skill_usage_store.get(), working_dir, close_full_screen_surface, [&screen]() { screen.PostEvent(Event::Custom); }, diff --git a/src/prompt/system_prompt.cpp b/src/prompt/system_prompt.cpp index 0540a9b9..4196d956 100644 --- a/src/prompt/system_prompt.cpp +++ b/src/prompt/system_prompt.cpp @@ -841,12 +841,22 @@ PromptContextBlock build_skills_index_context_prompt( const SkillRegistry* skills, int context_window_tokens, bool skill_view_available, - bool skills_list_available) { + bool skills_list_available, + const std::set* dormant_names) { PromptContextBlock block; if (!skills) return block; auto all = skills->list(); if (all.empty()) return block; + if (dormant_names && !dormant_names->empty()) { + all.erase( + std::remove_if(all.begin(), all.end(), + [&](const SkillMetadata& s) { + return dormant_names->count(s.name) != 0; + }), + all.end()); + if (all.empty()) return block; + } SkillIndexRenderResult rendered = format_skills_index_within_budget( all, skills_index_budget(context_window_tokens), skills_list_available); diff --git a/src/prompt/system_prompt.hpp b/src/prompt/system_prompt.hpp index 1872d5fa..aa85b416 100644 --- a/src/prompt/system_prompt.hpp +++ b/src/prompt/system_prompt.hpp @@ -2,6 +2,7 @@ #include "../tool/tool_executor.hpp" #include +#include #include #include @@ -111,11 +112,14 @@ SkillIndexRenderResult format_skills_index_within_budget( // Wrap the rendered index in a titled block with a content-hash cache key. // Null registry or empty skill list yields an empty block (not sent). +// `dormant_names` (optional): skill names to hide from the rendered index +// (dormant skills stay available via explicit mention but are not listed). PromptContextBlock build_skills_index_context_prompt( const SkillRegistry* skills, int context_window_tokens, bool skill_view_available = true, - bool skills_list_available = true); + bool skills_list_available = true, + const std::set* dormant_names = nullptr); // gitStatus 快照块(openspec add-git-context):把 collector 采集的快照文本 // 包成带缓存 key 的块。空文本(非仓库/采集失败/disabled)→ 空块不发送。 diff --git a/src/skills/skill_usage_store.cpp b/src/skills/skill_usage_store.cpp new file mode 100644 index 00000000..fec4504a --- /dev/null +++ b/src/skills/skill_usage_store.cpp @@ -0,0 +1,159 @@ +#include "skills/skill_usage_store.hpp" + +#include "utils/atomic_file.hpp" +#include "utils/logger.hpp" + +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace acecode { + +namespace { + +constexpr int kStateVersion = 1; +constexpr std::size_t kMaxStateFileBytes = 1024 * 1024; // 1 MB + +// Read the state file into a JSON object. Returns an empty object when the +// file is absent, oversized, corrupted, or has an unsupported version so the +// feature degrades gracefully instead of failing the caller. +nlohmann::json load_state_or_empty(const std::string& path) { + std::error_code ec; + if (!fs::exists(path, ec)) return nlohmann::json::object(); + if (fs::file_size(path, ec) > kMaxStateFileBytes) { + LOG_WARN("[skill_usage] state file too large, ignoring"); + return nlohmann::json::object(); + } + std::ifstream ifs(path); + if (!ifs) return nlohmann::json::object(); + try { + auto j = nlohmann::json::parse(ifs); + if (!j.is_object() || j.value("version", 0) != kStateVersion) { + LOG_WARN("[skill_usage] state file version mismatch, resetting"); + return nlohmann::json::object(); + } + return j; + } catch (const nlohmann::json::exception& e) { + LOG_WARN("[skill_usage] state file parse error: " + + std::string(e.what())); + return nlohmann::json::object(); + } +} + +} // namespace + +SkillUsageStore::SkillUsageStore(std::string state_path) + : state_path_(std::move(state_path)) {} + +bool SkillUsageStore::record(const std::string& skill_name, + const std::string& now_iso) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + state["version"] = kStateVersion; + auto& skills = state["skills"]; + if (!skills.is_object()) { + skills = nlohmann::json::object(); + } + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", now_iso}, + {"useCount", 1}, + {"pinned", false}}; + } else { + auto& entry = skills[skill_name]; + entry["lastUsedAt"] = now_iso; + entry["useCount"] = entry.value("useCount", 0u) + 1u; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +bool SkillUsageStore::is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const { + if (idle_days_ms == 0) return false; + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + auto& skills = state["skills"]; + if (!skills.is_object() || !skills.contains(skill_name)) return false; + auto& entry = skills[skill_name]; + if (!entry.is_object() || entry.value("pinned", false)) return false; + const std::string last_used = entry.value("lastUsedAt", ""); + if (last_used.empty()) return false; + const std::int64_t last_ms = parse_iso8601_to_epoch_ms(last_used); + if (last_ms == 0) return false; + return (now_epoch_ms - last_ms) > idle_days_ms; +} + +bool SkillUsageStore::set_pinned(const std::string& skill_name, bool pinned) { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + state["version"] = kStateVersion; + auto& skills = state["skills"]; + if (!skills.is_object()) { + skills = nlohmann::json::object(); + } + if (!skills.contains(skill_name)) { + skills[skill_name] = {{"lastUsedAt", ""}, + {"useCount", 0}, + {"pinned", pinned}}; + } else { + skills[skill_name]["pinned"] = pinned; + } + return atomic_write_file(state_path_, state.dump(2)); +} + +std::vector SkillUsageStore::get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const { + std::lock_guard lock(mu_); + auto state = load_state_or_empty(state_path_); + std::vector out; + auto& skills = state["skills"]; + if (!skills.is_object()) return out; + for (auto& [name, entry] : skills.items()) { + if (!entry.is_object()) continue; + SkillUsageSummary s; + s.name = name; + s.use_count = entry.value("useCount", 0u); + s.last_used_at = entry.value("lastUsedAt", ""); + s.pinned = entry.value("pinned", false); + if (idle_days_ms > 0 && !s.pinned && !s.last_used_at.empty()) { + const std::int64_t last_ms = + parse_iso8601_to_epoch_ms(s.last_used_at); + s.dormant = (last_ms > 0) && + (now_epoch_ms - last_ms) > idle_days_ms; + } + out.push_back(std::move(s)); + } + return out; +} + +void SkillUsageStore::reload() { + // The next access re-reads the file via load_state_or_empty; nothing + // to invalidate here. +} + +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso) { + if (iso.empty()) return 0; + std::tm tm = {}; + std::istringstream ss(iso); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) return 0; + int ms = 0; + if (ss.peek() == '.') { + ss.ignore(); + ss >> ms; + } + // Treat the parsed time as UTC (the 'Z' suffix / gmtime convention). + auto tp = std::chrono::system_clock::from_time_t( + std::mktime(&tm)) + + std::chrono::milliseconds(ms); + return std::chrono::duration_cast( + tp.time_since_epoch()) + .count(); +} + +} // namespace acecode diff --git a/src/skills/skill_usage_store.hpp b/src/skills/skill_usage_store.hpp new file mode 100644 index 00000000..48e129ac --- /dev/null +++ b/src/skills/skill_usage_store.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +namespace acecode { + +struct SkillUsageRecord { + std::string last_used_at; // ISO8601, e.g. "2026-08-01T10:00:00Z" + std::uint64_t use_count = 0; + bool pinned = false; +}; + +struct SkillUsageSummary { + std::string name; + std::uint64_t use_count = 0; + std::string last_used_at; + bool pinned = false; + bool dormant = false; // 实时判定结果(仅展示用,不落盘) +}; + +// Manages the skill-usage state file (~/.acecode/.skill_usage_state.json). +// Thread-safe: every public method serializes on an internal mutex. +class SkillUsageStore { +public: + // state_path: absolute path to the JSON state file. + // Does not touch disk until the first record()/is_dormant() call. + explicit SkillUsageStore(std::string state_path); + + // Record one successful use of skill_name at now (ISO8601). + // Creates the record if absent (use_count = 1); otherwise increments + // use_count and refreshes last_used_at. Best-effort: returns false on + // persistence failure, never throws. + bool record(const std::string& skill_name, const std::string& now_iso); + + // Dormancy predicate: + // !pinned && (now_epoch_ms - last_used_epoch_ms) > idle_days_ms + // Returns false when idle_days_ms == 0 (feature disabled), when the + // record is absent, or when the timestamp cannot be parsed. + bool is_dormant(const std::string& skill_name, + std::int64_t now_epoch_ms, + std::int64_t idle_days_ms) const; + + // Set or clear the pinned flag. Creates a record if absent. + // Best-effort: returns false on persistence failure, never throws. + bool set_pinned(const std::string& skill_name, bool pinned); + + // Snapshot of all records for display (TUI/Web). The `dormant` field is + // computed live from now_epoch_ms / idle_days_ms and is not persisted. + std::vector get_summary( + std::int64_t now_epoch_ms, std::int64_t idle_days_ms) const; + + // Re-read the state file on the next access (call after config changes). + void reload(); + +private: + std::string state_path_; + mutable std::mutex mu_; +}; + +// Parse an ISO8601 timestamp ("2026-08-01T10:00:00Z", optional .ms fraction) +// to epoch milliseconds. Returns 0 on parse failure or empty input. +std::int64_t parse_iso8601_to_epoch_ms(const std::string& iso); + +} // namespace acecode diff --git a/src/tui/settings/management_center.cpp b/src/tui/settings/management_center.cpp index 8fa41c3a..e122d081 100644 --- a/src/tui/settings/management_center.cpp +++ b/src/tui/settings/management_center.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -1310,9 +1311,44 @@ struct ManagementCenter::Impl { .size() : 0) + " files"), + render_skill_usage(*skill), }) | color(theme().ui.text_muted) | border; } + Element render_skill_usage(const SkillMetadata& skill) const { + if (!deps.skill_usage || !deps.config) { + return text(""); + } + const std::int64_t now_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const std::int64_t idle_ms = + static_cast(deps.config->skills.idle_days) * + 24LL * 60 * 60 * 1000; + const auto summaries = deps.skill_usage->get_summary( + now_ms, idle_ms); + std::string status; + std::uint64_t count = 0; + std::string last_used; + for (const auto& s : summaries) { + if (s.name == skill.name) { + status = s.pinned ? "[pinned]" + : s.dormant ? "[dormant]" + : "[active]"; + count = s.use_count; + last_used = s.last_used_at.empty() + ? "never" + : s.last_used_at.substr(0, 10); + break; + } + } + std::string line = "Usage " + status + " " + + std::to_string(count) + + " use(s), last " + last_used; + return text(line); + } + Element render_mcp_details() const { const McpServerInfo* row = selected_mcp(); if (!row) { diff --git a/src/tui/settings/management_center.hpp b/src/tui/settings/management_center.hpp index 007dc2ed..68c65598 100644 --- a/src/tui/settings/management_center.hpp +++ b/src/tui/settings/management_center.hpp @@ -3,6 +3,7 @@ #include "settings_state.hpp" #include "../../config/config.hpp" +#include "../../skills/skill_usage_store.hpp" #include @@ -27,6 +28,7 @@ struct ManagementCenterDependencies { McpManager* mcp = nullptr; ToolExecutor* tools = nullptr; HookManager* hooks = nullptr; + SkillUsageStore* skill_usage = nullptr; std::string cwd; std::function request_close; std::function post_event; diff --git a/src/tui_state.hpp b/src/tui_state.hpp index 2f087038..11c39534 100644 --- a/src/tui_state.hpp +++ b/src/tui_state.hpp @@ -3,6 +3,7 @@ #include "permissions.hpp" #include "provider/llm_provider.hpp" #include "path_reference/path_reference.hpp" +#include "skills/skill_usage_store.hpp" #include "tui/paste_handler.hpp" #include "tui/model_picker.hpp" #include "tui/mode_picker.hpp" @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -346,6 +348,9 @@ struct TuiState { // Application-owned, cross-launch counters loaded once at TUI startup. // Dropdown refreshes read only this cache and never touch the filesystem. std::map slash_command_usage_counts; + // Skill usage/dormancy state shared with the AgentLoop. Non-owning from + // the TUI side; main() owns the shared_ptr. Null in headless modes. + std::shared_ptr skill_usage_store; // @ path-reference dropdown. The token offsets are UTF-8 byte offsets so // they can be applied directly to input_text without lossy conversion. diff --git a/src/web/handlers/skills_handler.cpp b/src/web/handlers/skills_handler.cpp index bcb289e8..b923074a 100644 --- a/src/web/handlers/skills_handler.cpp +++ b/src/web/handlers/skills_handler.cpp @@ -2,11 +2,15 @@ #include "../../skills/skill_init.hpp" #include "../../skills/skill_registry.hpp" +#include "../../skills/skill_usage_store.hpp" #include "../../utils/utf8_path.hpp" #include "../../utils/logger.hpp" #include +#include +#include #include +#include #include namespace fs = std::filesystem; @@ -138,7 +142,10 @@ std::optional get_skill_body(const std::string& name, nlohmann::json build_skills_payload_with_roots( const std::vector& project_roots, const std::vector& global_roots, - const std::vector& disabled_list) { + const std::vector& disabled_list, + const SkillUsageStore* skill_usage, + std::int64_t now_epoch_ms, + int idle_days) { // 扫描时 disabled 置空 —— 禁用中的 skill 也保留完整元数据(描述/来源), // 而不是像旧实现那样只剩一个名字。 std::unordered_set project_root_keys; @@ -157,6 +164,18 @@ nlohmann::json build_skills_payload_with_roots( const std::unordered_set disabled( disabled_list.begin(), disabled_list.end()); + // Usage/dormancy lookup for display. Empty when no store is wired up. + std::unordered_map usage_by_name; + if (skill_usage) { + const std::int64_t idle_ms = + idle_days > 0 ? static_cast(idle_days) * 24LL * 60 * + 60 * 1000 + : 0; + for (const auto& s : skill_usage->get_summary(now_epoch_ms, idle_ms)) { + usage_by_name.emplace(s.name, s); + } + } + nlohmann::json arr = nlohmann::json::array(); std::unordered_set listed; for (const auto& s : registry.list()) { @@ -169,6 +188,18 @@ nlohmann::json build_skills_payload_with_roots( o["source"] = project_root_keys.count( path_to_utf8(s.scan_root.lexically_normal())) ? "project" : "global"; + const auto usage_it = usage_by_name.find(s.name); + if (usage_it != usage_by_name.end()) { + o["useCount"] = usage_it->second.use_count; + o["lastUsedAt"] = usage_it->second.last_used_at; + o["pinned"] = usage_it->second.pinned; + o["dormant"] = usage_it->second.dormant; + } else { + o["useCount"] = 0; + o["lastUsedAt"] = ""; + o["pinned"] = false; + o["dormant"] = false; + } listed.insert(s.name); arr.push_back(std::move(o)); } @@ -184,17 +215,32 @@ nlohmann::json build_skills_payload_with_roots( o["category"] = ""; o["enabled"] = false; o["source"] = ""; + o["useCount"] = 0; + o["lastUsedAt"] = ""; + o["pinned"] = false; + o["dormant"] = false; arr.push_back(std::move(o)); } return arr; } nlohmann::json build_skills_payload(const AppConfig& cfg, - const std::string& workspace_cwd_utf8) { + const std::string& workspace_cwd_utf8, + const SkillUsageStore* skill_usage) { + std::int64_t now_epoch_ms = 0; + if (skill_usage) { + now_epoch_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + } return build_skills_payload_with_roots( project_skill_scan_roots(cfg, workspace_cwd_utf8), global_skill_scan_roots(cfg), - cfg.skills.disabled); + cfg.skills.disabled, + skill_usage, + now_epoch_ms, + cfg.skills.idle_days); } } // namespace acecode::web diff --git a/src/web/handlers/skills_handler.hpp b/src/web/handlers/skills_handler.hpp index 80ec69e3..28b92c07 100644 --- a/src/web/handlers/skills_handler.hpp +++ b/src/web/handlers/skills_handler.hpp @@ -21,6 +21,7 @@ namespace acecode { class SkillRegistry; +class SkillUsageStore; } namespace acecode::web { @@ -68,11 +69,15 @@ std::optional get_skill_body(const std::string& name, nlohmann::json build_skills_payload_with_roots( const std::vector& project_roots, const std::vector& global_roots, - const std::vector& disabled); + const std::vector& disabled, + const SkillUsageStore* skill_usage = nullptr, + std::int64_t now_epoch_ms = 0, + int idle_days = 0); // 便捷封装:扫描根取自 skill_init 的 project/global 根构成(与 // initialize_skill_registry 一致),disabled 取自 cfg.skills.disabled。 nlohmann::json build_skills_payload(const AppConfig& cfg, - const std::string& workspace_cwd_utf8); + const std::string& workspace_cwd_utf8, + const SkillUsageStore* skill_usage = nullptr); } // namespace acecode::web diff --git a/src/web/routes/routes_experts.cpp b/src/web/routes/routes_experts.cpp index bfd88e2c..023d0254 100644 --- a/src/web/routes/routes_experts.cpp +++ b/src/web/routes/routes_experts.cpp @@ -187,7 +187,8 @@ void WebServer::Impl::register_experts() { json skills = json::array(); if (config_snapshot) { const auto payload = - build_skills_payload(*config_snapshot, workspace->cwd); + build_skills_payload(*config_snapshot, workspace->cwd, + deps.skill_usage_store); for (const auto& item : payload) { const std::string id = item.value("name", std::string{}); diff --git a/src/web/routes/routes_files.cpp b/src/web/routes/routes_files.cpp index f09ec6f8..8ad98ec8 100644 --- a/src/web/routes/routes_files.cpp +++ b/src/web/routes/routes_files.cpp @@ -305,7 +305,8 @@ void WebServer::Impl::register_skills() { ws = compatibility_workspace(); } std::shared_lock config_lock(app_config_mu); - arr = build_skills_payload(*deps.app_config, ws->cwd); + arr = build_skills_payload(*deps.app_config, ws->cwd, + deps.skill_usage_store); } crow::response r(arr.dump()); r.add_header("Content-Type", "application/json"); diff --git a/src/web/server.hpp b/src/web/server.hpp index cc02a841..bb913724 100644 --- a/src/web/server.hpp +++ b/src/web/server.hpp @@ -35,6 +35,7 @@ class PtySessionRegistry; class SessionClient; class SessionRegistry; class SkillRegistry; +class SkillUsageStore; class ExpertRegistry; class ToolExecutor; } // namespace acecode @@ -81,6 +82,9 @@ struct WebServerDeps { acecode::desktop::WorkspaceRegistry* workspace_registry = nullptr; // 非 const:PUT /api/skills/:name 要写 cfg.skills.disabled 后调 set_disabled + reload。 SkillRegistry* skill_registry = nullptr; + // Skill usage/dormancy state shared with the TUI session. Null disables + // usage fields on /api/skills (headless or daemon-only processes). + SkillUsageStore* skill_usage_store = nullptr; // Daemon-global provider handle retained for routes/fixtures that inspect // process-level provider state. Current web session model switching is // session-scoped through SessionRegistry. diff --git a/tests/skills/skill_usage_store_test.cpp b/tests/skills/skill_usage_store_test.cpp new file mode 100644 index 00000000..af506063 --- /dev/null +++ b/tests/skills/skill_usage_store_test.cpp @@ -0,0 +1,116 @@ +#include "skills/skill_usage_store.hpp" + +#include + +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr std::int64_t kDayMs = 24LL * 60 * 60 * 1000; + +fs::path temp_state_file(const std::string& name) { + fs::path tmp = fs::temp_directory_path() / + ("acecode_skill_usage_" + name + ".json"); + std::error_code ec; + fs::remove(tmp, ec); + return tmp; +} + +} // namespace + +TEST(SkillUsageStoreTest, ParseIso8601) { + const auto ms = acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z"); + EXPECT_GT(ms, 0); + EXPECT_EQ(ms, + acecode::parse_iso8601_to_epoch_ms("2026-08-01T10:00:00Z")); + // 无效输入返回 0 + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("not-a-date")); + EXPECT_EQ(0, acecode::parse_iso8601_to_epoch_ms("")); +} + +TEST(SkillUsageStoreTest, RecordCreatesEntry) { + const fs::path tmp = temp_state_file("record"); + acecode::SkillUsageStore store(tmp.string()); + + EXPECT_TRUE(store.record("pdf", "2026-08-01T10:00:00Z")); + // 新记录应 active + EXPECT_FALSE(store.is_dormant("pdf", 1785578400000LL, 30 * kDayMs)); + auto s = store.get_summary(1785578400000LL, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].name, "pdf"); + EXPECT_EQ(s[0].use_count, 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, DormantAfterThreshold) { + const fs::path tmp = temp_state_file("dormant"); + acecode::SkillUsageStore store(tmp.string()); + + // 在 t=used_ms 使用,距今 40 天 > 30 天阈值 + const std::int64_t used_ms = 1785578400000LL; // 2026-08-01T10:00:00Z + const std::int64_t now_ms = used_ms + 40 * kDayMs; + EXPECT_TRUE(store.record("xlsx", "2026-08-01T10:00:00Z")); + EXPECT_TRUE(store.is_dormant("xlsx", now_ms, 30 * kDayMs)); + auto s = store.get_summary(now_ms, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_TRUE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, PinnedSkillNeverDormant) { + const fs::path tmp = temp_state_file("pinned"); + acecode::SkillUsageStore store(tmp.string()); + + EXPECT_TRUE(store.record("pinned_skill", "2026-06-01T10:00:00Z")); + EXPECT_TRUE(store.set_pinned("pinned_skill", true)); + const std::int64_t now_ms = 1780308000000LL + 60 * kDayMs; + EXPECT_FALSE(store.is_dormant("pinned_skill", now_ms, 30 * kDayMs)); + auto s = store.get_summary(now_ms, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + EXPECT_TRUE(s[0].pinned); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IdleDaysZeroDisablesFeature) { + const fs::path tmp = temp_state_file("zero"); + acecode::SkillUsageStore store(tmp.string()); + + EXPECT_TRUE(store.record("skill", "2020-01-01T00:00:00Z")); + EXPECT_FALSE(store.is_dormant("skill", 1785578400000LL, 0)); + auto s = store.get_summary(1785578400000LL, 0); + ASSERT_EQ(s.size(), 1u); + EXPECT_FALSE(s[0].dormant); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, IncrementUseCount) { + const fs::path tmp = temp_state_file("incr"); + acecode::SkillUsageStore store(tmp.string()); + + store.record("pdf", "2026-08-01T10:00:00Z"); + store.record("pdf", "2026-08-02T10:00:00Z"); + store.record("pdf", "2026-08-03T10:00:00Z"); + auto s = store.get_summary(1785578400000LL, 30 * kDayMs); + ASSERT_EQ(s.size(), 1u); + EXPECT_EQ(s[0].use_count, 3u); + fs::remove(tmp); +} + +TEST(SkillUsageStoreTest, CorruptStateDegradesGracefully) { + const fs::path tmp = temp_state_file("corrupt"); + { + std::ofstream ofs(tmp); + ofs << "{ this is not valid json"; + } + acecode::SkillUsageStore store(tmp.string()); + + // 损坏文件:record 仍成功(内部重置),不抛异常 + EXPECT_TRUE(store.record("pdf", "2026-08-01T10:00:00Z")); + EXPECT_FALSE(store.is_dormant("pdf", 1785578400000LL, 30 * kDayMs)); + fs::remove(tmp); +} diff --git a/tests/web/skills_handler_test.cpp b/tests/web/skills_handler_test.cpp index f4bd4c07..1ca84d18 100644 --- a/tests/web/skills_handler_test.cpp +++ b/tests/web/skills_handler_test.cpp @@ -10,6 +10,7 @@ #include "config/config.hpp" #include "skills/skill_registry.hpp" +#include "skills/skill_usage_store.hpp" #include #include @@ -338,3 +339,45 @@ TEST_F(SkillsHandlerTest, BuildSkillsPayloadDeduplicatesByNameProjectWins) { EXPECT_EQ((*entry)["source"], "project"); EXPECT_EQ((*entry)["description"], "project copy"); } + +// 场景: 传入 SkillUsageStore → payload 每条带 useCount/lastUsedAt/pinned/ +// dormant;未传入(默认)时这些字段以默认值(0/""/false/false)出现,保证 +// 前端字段形状稳定。 +TEST_F(SkillsHandlerTest, BuildSkillsPayloadCarriesUsageFields) { + const auto global_root = tmp_root / "home" / ".acecode" / "skills"; + write_skill(global_root, "used-skill", "used recently"); + write_skill(global_root, "idle-skill", "idle long enough"); + + const fs::path state = tmp_root / "skill_usage.json"; + acecode::SkillUsageStore store(state.string()); + // 最近使用(不休眠) + store.record("used-skill", "2026-08-10T10:00:00Z"); + store.record("used-skill", "2026-08-11T10:00:00Z"); + // 60 天前使用(超过 30 天阈值 → dormant) + store.record("idle-skill", "2026-06-01T10:00:00Z"); + + const std::int64_t now_ms = + acecode::parse_iso8601_to_epoch_ms("2026-08-15T10:00:00Z"); + + auto arr = acecode::web::build_skills_payload_with_roots( + {}, {global_root}, /*disabled=*/{}, &store, now_ms, /*idle_days=*/30); + + const auto* used = find_entry(arr, "used-skill"); + ASSERT_NE(used, nullptr); + EXPECT_EQ((*used)["useCount"].get(), 2u); + EXPECT_FALSE((*used)["dormant"].get()); + + const auto* idle = find_entry(arr, "idle-skill"); + ASSERT_NE(idle, nullptr); + EXPECT_TRUE((*idle)["dormant"].get()); + + // 无 store 时字段形状保持稳定 + auto arr_no_store = acecode::web::build_skills_payload_with_roots( + {}, {global_root}, /*disabled=*/{}); + const auto* no_store = find_entry(arr_no_store, "used-skill"); + ASSERT_NE(no_store, nullptr); + EXPECT_EQ((*no_store)["useCount"].get(), 0u); + EXPECT_FALSE((*no_store)["dormant"].get()); + + fs::remove(state); +}