diff --git a/.agents/skills/anthropic-skills b/.agents/skills/anthropic-skills new file mode 160000 index 0000000000..98669c11ca --- /dev/null +++ b/.agents/skills/anthropic-skills @@ -0,0 +1 @@ +Subproject commit 98669c11ca63e9c81c11501e1437e5c47b556621 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..dd0f723f3d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,54 @@ +{ + "permissions": { + "allow": [ + "Bash(git rm:*)", + "Bash(git commit:*)", + "Bash(grep -ri buddy /mnt/user/appdata/claude-code/src/ --include=*.ts --include=*.tsx --include=*.js -l)", + "Bash(grep -ri buddy /mnt/user/appdata/claude-code/ --include=*.md --include=*.json -l)", + "Bash(git remote:*)", + "Bash(git fetch:*)", + "Bash(git merge:*)", + "Bash(grep -rn \"getSettings_DEPRECATED\\\\|getSettingsForSource\\\\|useSettings\\\\b\" /mnt/user/appdata/claude-code/src/hooks/useSettings.ts /mnt/user/appdata/claude-code/src/utils/settings/ --include=*.ts --include=*.tsx)", + "Bash(grep -rn extractQuotaStatusFromHeaders src/ --include=*.ts --include=*.tsx)", + "Bash(find src/components -name Usage* -o -name usage*)", + "Bash(find src:*)", + "Bash(grep -rn \"extractQuotaStatusFromHeaders\\\\|extractQuotaStatusFromError\" src/services/api/ --include=*.ts --include=*.tsx)", + "Bash(grep -rn \"getRawUtilization\\\\|checkQuotaStatus\" /mnt/user/appdata/claude-code/src/ --include=*.ts --include=*.tsx)", + "Bash(grep -n TerminalSize /mnt/user/appdata/claude-code/src/ink/components/TerminalSizeContext.ts*)", + "Bash(ls /mnt/user/appdata/claude-code/src/components/BuiltinStatusLine*)", + "Bash(ls /mnt/user/appdata/claude-code/src/components/Builtin*)", + "Bash(grep -r export.*formatCountdown src/components/)", + "Bash(bun test:*)", + "Bash(bun run:*)", + "Bash(npx biome:*)", + "Bash(git status:*)", + "Bash(git add:*)", + "Bash(gh --version)", + "Bash(gh auth:*)", + "Bash(curl -sL https://github.com/cli/cli/releases/latest/download/gh_2.74.1_linux_amd64.tar.gz -o /tmp/gh.tar.gz)", + "Bash(tar -xzf /tmp/gh.tar.gz -C /tmp)", + "Read(//tmp/**)", + "Bash(curl -sI https://github.com/cli/cli/releases/latest)", + "Bash(curl -sL \"https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz\" -o /tmp/gh.tar.gz)", + "Bash(cp /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/gh)", + "Bash(chmod +x /usr/local/bin/gh)", + "Bash(git checkout:*)", + "Bash(git cherry-pick:*)", + "Bash(git push:*)", + "Bash(gh pr:*)", + "Bash(gh api:*)", + "Bash(curl:*)", + "Bash(ssh:*)", + "Bash(git reset:*)", + "WebSearch", + "Bash(mkdir -p ~/.claude/skills)", + "Read(//root/.claude/**)", + "Bash(mkdir -p /mnt/user/appdata/claude-code/.claude/skills)", + "Bash(git clone:*)", + "Bash(python3 -m json.tool)", + "Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(f'''' {d[\"\"name\"\"]}/ \\({d[\"\"type\"\"]}\\)''''\\) for d in data]\")", + "Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(f'''' {d[\"\"name\"\"]} \\({d[\"\"type\"\"]}\\)''''\\) for d in data]\")", + "Bash(git pull:*)" + ] + } +} diff --git a/.claude/skills/anthropic-skills b/.claude/skills/anthropic-skills new file mode 160000 index 0000000000..98669c11ca --- /dev/null +++ b/.claude/skills/anthropic-skills @@ -0,0 +1 @@ +Subproject commit 98669c11ca63e9c81c11501e1437e5c47b556621 diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100755 new mode 100644 diff --git a/.gitignore b/.gitignore index ec014c2209..35122d0fea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,12 @@ coverage .idea .vscode *.suo -*.lock \ No newline at end of file +*.lock + +# Local settings (contains machine-specific paths and permissions) +.claude/settings.local.json +.claude/skills/ +.agents/skills/ + +# Screenshots and temp files +QQ*.png \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..20f9aab0b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. The codebase has ~1341 tsc errors from decompilation (mostly `unknown`/`never`/`{}` types) — these do **not** block Bun runtime execution. + +## Commands + +```bash +# Install dependencies +bun install + +# Dev mode (runs cli.tsx with MACRO defines injected via -d flags) +bun run dev + +# Pipe mode +echo "say hello" | bun run src/entrypoints/cli.tsx -p + +# Build (code splitting, outputs dist/cli.js + ~450 chunk files) +bun run build + +# Test +bun test # run all tests +bun test src/utils/__tests__/hash.test.ts # run single file +bun test --coverage # with coverage report + +# Lint & Format (Biome) +bun run lint # check only +bun run lint:fix # auto-fix +bun run format # format all src/ +``` + +详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。 + +## Architecture + +### Runtime & Build + +- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs. +- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + ~450 chunk files。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。 +- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。`scripts/defines.ts` 集中管理 define map。 +- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. +- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`. +- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。 + +### Entry & Bootstrap + +1. **`src/entrypoints/cli.tsx`** — True entrypoint. Injects runtime polyfills at the top: + - `feature()` always returns `false` (all feature flags disabled, skipping unimplemented branches). + - `globalThis.MACRO` — simulates build-time macro injection (VERSION, BUILD_TIME, etc.). + - `BUILD_TARGET`, `BUILD_ENV`, `INTERFACE_TYPE` globals. +2. **`src/main.tsx`** — Commander.js CLI definition. Parses args, initializes services (auth, analytics, policy), then launches the REPL or runs in pipe mode. +3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog). + +### Core Loop + +- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop. +- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen. +- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts. + +### API Layer + +- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events. +- Supports multiple providers: Anthropic direct, AWS Bedrock, Google Vertex, Azure. +- Provider selection in `src/utils/model/providers.ts`. + +### Tool System + +- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). +- **`src/tools.ts`** — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. +- **`src/tools//`** — Each tool in its own directory (e.g., `BashTool`, `FileEditTool`, `GrepTool`, `AgentTool`). +- Tools define: `name`, `description`, `inputSchema` (JSON Schema), `call()` (execution), and optionally a React component for rendering results. + +### UI Layer (Ink) + +- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection. +- **`src/ink/`** — Custom Ink framework (forked/internal): custom reconciler, hooks (`useInput`, `useTerminalSize`, `useSearchHighlight`), virtual list rendering. +- **`src/components/`** — React components rendered in terminal via Ink. Key ones: + - `App.tsx` — Root provider (AppState, Stats, FpsMetrics). + - `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering. + - `PromptInput/` — User input handling. + - `permissions/` — Tool permission approval UI. +- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout. + +### State Management + +- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc. +- **`src/state/store.ts`** — Zustand-style store for AppState. +- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts). + +### Context & System Prompt + +- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files). +- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy. + +### Feature Flag System + +All `feature('FLAG_NAME')` calls come from `bun:bundle` (a build-time API). In this decompiled version, `feature()` is polyfilled to always return `false` in `cli.tsx`. This means all Anthropic-internal features (COORDINATOR_MODE, KAIROS, PROACTIVE, etc.) are disabled. + +### Stubbed/Deleted Modules + +| Module | Status | +|--------|--------| +| Computer Use (`@ant/*`) | Stub packages in `packages/@ant/` | +| `*-napi` packages (audio, image, url, modifiers) | Stubs in `packages/` (except `color-diff-napi` which is fully implemented) | +| Analytics / GrowthBook / Sentry | Empty implementations | +| Magic Docs / Voice Mode / LSP Server | Removed | +| Plugins / Marketplace | Removed | +| MCP OAuth | Simplified | + +### Key Type Files + +- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers. +- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`. +- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.). +- **`src/types/permissions.ts`** — Permission mode and result types. + +## Testing + +- **框架**: `bun:test`(内置断言 + mock) +- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` +- **集成测试**: `tests/integration/`,共享 mock/fixture 在 `tests/mocks/` +- **命名**: `describe("functionName")` + `test("behavior description")`,英文 +- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入) +- **当前状态**: 1286 tests / 67 files / 0 fail(详见 `docs/testing-spec.md` 的覆盖状态表和评分) + +## Working with This Codebase + +- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime. +- **`feature()` is always `false`** — any code behind a feature flag is dead code in this build. +- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal. +- **`bun:bundle` import** — In `src/main.tsx` and other files, `import { feature } from 'bun:bundle'` works at build time. At dev-time, the polyfill in `cli.tsx` provides it. +- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid. +- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。 +- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。 diff --git a/QQ20260402-192932.png b/QQ20260402-192932.png new file mode 100644 index 0000000000..797f9d43c9 Binary files /dev/null and b/QQ20260402-192932.png differ diff --git a/docs/claude-buddy-system/IMPLEMENTATION-PLAN.md b/docs/claude-buddy-system/IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000000..8fc594ecca --- /dev/null +++ b/docs/claude-buddy-system/IMPLEMENTATION-PLAN.md @@ -0,0 +1,804 @@ +# Buddy 恢复实施文档 + +## 1. 文档目的 + +本文档用于指导当前仓库中的 `buddy` 子系统恢复开发,目标是生成一套可直接实施、可单测、可冒烟验证的本地闭环实现。 + +本文档覆盖: + +- 现状判定 +- 恢复目标 +- 文件级改动说明 +- 函数签名与行为约束 +- 分阶段实施顺序 +- 单元测试计划 +- 冒烟测试计划 +- 风险点与回滚点 + +本文档不覆盖: + +- Anthropic 内部 first-party 远程 API 接入 +- 生产 GrowthBook 灰度策略 +- 模型生成 soul 的在线实现 + +第一阶段只恢复开源仓库中可以独立运行的本地版本。 + +## 2. 当前现状 + +### 2.1 已存在的 buddy 资产 + +当前仓库中,`buddy` 并不是空白功能,以下链路已经存在: + +- 命令注册位:`src/commands.ts` +- UI 组件:`src/buddy/CompanionSprite.tsx` +- 数据模型:`src/buddy/types.ts` +- 骨架生成逻辑:`src/buddy/companion.ts` +- sprite 渲染:`src/buddy/sprites.ts` +- teaser / live 检查:`src/buddy/useBuddyNotification.tsx` +- prompt 注入:`src/buddy/prompt.ts` +- attachment 注入:`src/utils/attachments.ts` +- system reminder 转换:`src/utils/messages.ts` +- config 字段:`src/utils/config.ts` +- AppState 字段:`src/state/AppStateStore.ts` +- REPL 接线:`src/screens/REPL.tsx` + +### 2.2 当前缺失点 + +当前真正缺失的是动作层和 observer 实现: + +- `src/commands/buddy/index.ts` 只是空 stub,当前不是合法 `Command` +- 没有 `src/commands/buddy/buddy.ts` +- 没有 hatch / off / on / pet 的实现 +- `companionPetAt` 只有定义和读取,没有写入 +- `companionMuted` 有读取,没有 slash command 写入 +- `REPL.tsx` 调用了全局 `fireCompanionObserver(...)` +- 仓库中只有 `src/types/global.d.ts` 的声明,没有 observer 实现 + +### 2.3 当前已确认的真实约束 + +以下约束已经在源码中确认过: + +- `StoredCompanion` 只持久化 soul,不持久化 bones +- `companionMuted` 属于持久态,应写入 global config +- `companionPetAt` 属于瞬时态,应写入 AppState,而不是 config +- `companionReaction` 属于瞬时态,应写入 AppState +- `local-jsx` 命令既可以用 `onDone(...)` 输出文本,也可以 `return null` +- 当前 prompt 注入链在 muted 时会自动关闭,无需额外修改 + +## 3. 恢复目标 + +第一阶段恢复的用户可见功能定义如下: + +1. `/buddy` +2. `/buddy pet` +3. `/buddy off` +4. `/buddy on` +5. 本地 observer 气泡评论 + +每条命令的目标行为如下。 + +### 3.1 `/buddy` + +- 若无 companion,则 hatch 一只新的 companion +- hatch 后写入 `config.companion` +- hatch 后自动设为非静音 +- 返回 hatch 结果文本卡片 +- 若已有 companion,则直接显示卡片 + +### 3.2 `/buddy pet` + +- 若无 companion,则显示提示 +- 若有 companion,则触发 hearts 动画 +- 若当前 muted,则自动解除静音,保证动画可见 + +### 3.3 `/buddy off` + +- 写入 `config.companionMuted = true` +- companion sprite 隐藏 +- companion prompt 注入停止 + +### 3.4 `/buddy on` + +- 写入 `config.companionMuted = false` +- companion sprite 恢复 +- companion prompt 注入恢复 + +### 3.5 observer + +- 在用户提到 companion 名字时触发 reaction +- 在检测到测试失败文本时触发 reaction +- 在检测到错误文本时触发 reaction +- 第一阶段 reaction 仅由本地模板生成,不依赖远程 API + +## 4. 非目标 + +以下能力不属于第一阶段目标: + +- 使用模型生成 `name` 和 `personality` +- 调用远程 `buddy_react` API +- Anthropic 内部 OAuth / organization gating +- 复杂 hatch 动画 modal +- 丰富的 rate limit / 灰度策略接入 + +这些能力可以在本地闭环稳定后作为第二阶段扩展。 + +## 5. 设计原则 + +### 5.1 保留现有 buddy 系统结构 + +不重写已存在模块: + +- `companion.ts` +- `types.ts` +- `sprites.ts` +- `CompanionSprite.tsx` +- `prompt.ts` +- `attachments.ts` +- `messages.ts` + +这些模块已经构成 buddy 的显示层和上下文注入层。 + +### 5.2 只补动作层 + +第一阶段只新增或修改: + +- buddy 命令元数据 +- buddy 命令分发器 +- hatch 逻辑 +- card 文本渲染 +- observer +- REPL 中的 observer 接线 + +### 5.3 第一阶段使用本地 deterministic soul + +理由: + +- 可在无远程依赖时立即闭环 +- 可单测 +- 可冒烟 +- 不会把恢复工作扩展为 query orchestration 工作 + +### 5.4 observer 必须收回仓库内 + +当前全局 `fireCompanionObserver(...)` 只有声明没有实现。继续依赖全局实现会让 buddy 在仓库内不可维护。 + +## 6. 文件级实施方案 + +### 6.1 修改 `src/commands/buddy/index.ts` + +当前文件内容是空 stub,必须替换为合法命令元数据。 + +目标结构: + +```ts +import type { Command } from '../../commands.js' +import { isBuddyLive } from '../../buddy/useBuddyNotification.js' + +const buddy = { + type: 'local-jsx', + name: 'buddy', + description: 'Hatch a coding companion · pet, off', + argumentHint: '[pet|off|on]', + immediate: true, + get isHidden() { + return !isBuddyLive() + }, + load: () => import('./buddy.js'), +} satisfies Command + +export default buddy +``` + +说明: + +- 使用 `local-jsx`,不是 `local` +- `immediate: true`,保证命令即时执行 +- 第一阶段即便不返回 JSX,也仍保留 `local-jsx`,为未来 hatch 动画保留接口 + +### 6.2 新建 `src/commands/buddy/buddy.ts` + +该文件是 buddy 的命令分发入口。 + +导出函数签名: + +```ts +import type React from 'react' +import type { + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import type { ToolUseContext } from '../../Tool.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext & LocalJSXCommandContext, + args: string, +): Promise +``` + +职责: + +- 解析 `args` +- 获取 `companion` +- 分发到 `off / on / pet / default` +- 通过 `onDone(...)` 输出文本 +- 第一阶段统一 `return null` + +不负责: + +- bones 生成细节 +- soul 生成细节 +- 卡片文本拼装 +- observer 逻辑 + +推荐内部结构: + +```ts +const subcommand = args.trim().toLowerCase() +const companion = getCompanion() + +switch (subcommand) { + case 'off': + case 'on': + case 'pet': + case '': + default: +} +``` + +### 6.3 新建 `src/commands/buddy/hatch.ts` + +该文件负责 hatch 逻辑和本地 soul 生成。 + +建议导出: + +```ts +import type { + Companion, + CompanionBones, + CompanionSoul, +} from '../../buddy/types.js' + +export function buildLocalSoul( + bones: CompanionBones, + inspirationSeed: number, +): CompanionSoul + +export function hatchCompanion(): Companion +``` + +#### `buildLocalSoul(...)` 行为 + +输入: + +- `bones` +- `inspirationSeed` + +输出: + +- `name` +- `personality` + +约束: + +- 名字长度建议 1-14 字符 +- personality 一句话,不超过 120 字符 + +实现建议: + +- `name` 由 `species`、`rarity`、`inspirationSeed` 决定 +- `personality` 由最高属性和 rarity 决定 + +#### `hatchCompanion()` 行为 + +固定顺序: + +1. `const userId = companionUserId()` +2. `const { bones, inspirationSeed } = roll(userId)` +3. `const soul = buildLocalSoul(bones, inspirationSeed)` +4. `const hatchedAt = Date.now()` +5. `saveGlobalConfig(...)` 写入 `companion` +6. 返回 `{ ...bones, ...soul, hatchedAt }` + +写入结构必须是: + +```ts +{ + companion: { + name, + personality, + hatchedAt, + }, + companionMuted: false, +} +``` + +禁止写入 bones 到 config。 + +### 6.4 新建 `src/commands/buddy/card.ts` + +该文件负责将 companion 转成用户可读文本卡片。 + +建议导出: + +```ts +import type { Companion } from '../../buddy/types.js' + +export function formatCompanionCard(companion: Companion): string +export function formatHatchMessage(companion: Companion): string +``` + +#### `formatCompanionCard(...)` 输出结构 + +卡片建议包含: + +- sprite +- name +- species +- rarity +- stars +- shiny 标记 +- personality +- 全部 stat +- 使用提示 + +建议输出模板: + +```text + + +Miso +cat · RARE ★★★ + +Judges your code quietly, and usually correctly. + +DEBUGGING ███████░░░ 74 +PATIENCE ████░░░░░░ 41 +CHAOS ██░░░░░░░░ 22 +WISDOM ██████░░░░ 63 +SNARK ████████░░ 81 + +Miso is here · it'll chime in as you code +say its name to get its take · /buddy pet · /buddy off +``` + +依赖: + +- `renderSprite(companion, 0)` +- `RARITY_STARS` +- `STAT_NAMES` + +#### `formatHatchMessage(...)` + +第一阶段可以直接基于 `formatCompanionCard(...)` 封装,例如在顶部加两行: + +```text +hatching a coding buddy… +it'll watch you work and occasionally have opinions +``` + +### 6.5 新建 `src/buddy/observer.ts` + +该文件负责本地 observer。 + +导出函数签名: + +```ts +import type { Message } from '../types/message.js' + +export async function fireCompanionObserver( + messages: Message[], + callback: (reaction: string | undefined) => void, +): Promise +``` + +#### 第一阶段触发条件 + +1. `addressed` +2. `test_failed` +3. `error` + +建议内部辅助函数: + +```ts +function findLatestUserText(messages: Message[]): string | undefined +function detectReactionReason(messages: Message[], companionName: string): ReactionReason | null +function buildLocalReaction(reason: ReactionReason, companionName: string): string | undefined +``` + +#### 正则建议 + +测试失败: + +```ts +/\b[1-9]\d* (failed|failing)\b|\btests? failed\b|^FAIL(ED)?\b| ✗ | ✘ /im +``` + +错误: + +```ts +/\berror:|\bexception\b|\btraceback\b|\bpanicked at\b|\bfatal:|exit code [1-9]/i +``` + +#### 输出约束 + +- reaction 为单行 +- 长度小于等于 80 字符 +- 若 muted 或无 companion,则必须返回 `undefined` + +### 6.6 修改 `src/screens/REPL.tsx` + +将当前隐式全局调用改为显式模块依赖。 + +当前需要替换的位置是: + +- `fireCompanionObserver(messagesRef.current, ...)` + +修改目标: + +- 新增 `import { fireCompanionObserver } from '../buddy/observer.js'` +- 保留原有 `setAppState` 写 `companionReaction` 的逻辑 + +不改: + +- `CompanionSprite` 渲染位置 +- `companionReaction` 清理逻辑 +- fullscreen / narrow 相关布局逻辑 + +### 6.7 收尾修改 `src/types/global.d.ts` + +在 observer 显式模块化后,删除: + +```ts +declare function fireCompanionObserver(...) +``` + +该步骤放在 Phase 4,避免在过渡期打断编译。 + +## 7. 命令行为定义 + +### 7.1 `/buddy off` + +行为: + +- `saveGlobalConfig(current => ({ ...current, companionMuted: true }))` +- `onDone('companion muted', { display: 'system' })` +- `return null` + +### 7.2 `/buddy on` + +行为: + +- `saveGlobalConfig(current => ({ ...current, companionMuted: false }))` +- `onDone('companion unmuted', { display: 'system' })` +- `return null` + +### 7.3 `/buddy pet` + +行为: + +- 若 `getCompanion()` 为 `undefined` + - `onDone('no companion yet · run /buddy first', { display: 'system' })` +- 若有 companion + - 若 `companionMuted === true`,先解除静音 + - `context.setAppState(prev => ({ ...prev, companionPetAt: Date.now() }))` + - `onDone(undefined, { display: 'skip' })` + +说明: + +- 推荐 `pet` 时自动解除静音 +- 推荐 `display: 'skip'`,避免在 transcript 留下低价值输出 + +### 7.4 `/buddy` + +无参数时: + +- 若已有 companion + - `onDone(formatCompanionCard(companion), { display: 'system' })` +- 若无 companion + - `const companion = hatchCompanion()` + - `onDone(formatHatchMessage(companion), { display: 'system' })` + +### 7.5 非法参数 + +行为: + +```text +usage: /buddy [pet|off|on] +``` + +输出方式: + +- `display: 'system'` + +## 8. 状态链说明 + +### 8.1 持久态 + +写入位置: + +- `config.companion` +- `config.companionMuted` + +消费位置: + +- `getCompanion()` +- `prompt.ts` +- `CompanionSprite.tsx` +- `PromptInput.tsx` + +### 8.2 瞬时态 + +写入位置: + +- `AppState.companionPetAt` +- `AppState.companionReaction` + +消费位置: + +- `CompanionSprite.tsx` +- `REPL.tsx` + +禁止: + +- 将 `companionPetAt` 写入 global config +- 将 `companionReaction` 写入 global config + +## 9. 测试实施方案 + +### 9.1 新增测试文件 + +- `src/commands/buddy/buddy.test.ts` +- `src/commands/buddy/card.test.ts` +- `src/buddy/observer.test.ts` + +### 9.2 测试前提 + +已确认 `src/utils/config.ts` 在 `NODE_ENV=test` 下有测试态 config: + +- `getGlobalConfig()` 返回测试对象 +- `saveGlobalConfig(...)` 直接写测试对象 + +这意味着 buddy 的 config 行为可以直接单测,不需要自建文件 mock。 + +### 9.3 `buddy.test.ts` 用例 + +1. `/buddy` 首次执行时会创建 `config.companion` +2. 再次执行 `/buddy` 不会覆盖现有 companion +3. `/buddy off` 会设置 `companionMuted = true` +4. `/buddy on` 会设置 `companionMuted = false` +5. `/buddy pet` 在无 companion 时返回提示 +6. `/buddy pet` 在有 companion 时调用 `setAppState(...)` +7. 非法参数返回 usage + +### 9.4 `card.test.ts` 用例 + +1. 卡片包含名字 +2. 卡片包含 species +3. 卡片包含 rarity +4. 卡片包含 personality +5. 卡片包含所有 stat 名称 +6. 卡片至少包含一行 sprite + +### 9.5 `observer.test.ts` 用例 + +1. 提到 companion 名字时触发 reaction +2. 测试失败文本触发 reaction +3. 错误文本触发 reaction +4. muted 时不触发 +5. 无 companion 时不触发 + +## 10. 冒烟测试 + +以下 smoke 为实施完成后的人工验证脚本。 + +### 10.1 启动 + +命令: + +```bash +bun run dev +``` + +预期: + +- 若 dev build 中启用了 `feature('BUDDY')`,则命令系统中应存在 `/buddy` +- 在 2026-04-02 这个日期上,应处于 live 时间范围内 + +说明: + +- 当前会话中 `bun` 不可用,因此文档内保留为待执行 smoke + +### 10.2 首次孵化 + +输入: + +```text +/buddy +``` + +预期: + +- 创建 companion +- 显示 hatch 文案和卡片 +- companion sprite 出现在输入区旁 +- 全局 config 出现 `companion` + +### 10.3 再次查看 + +输入: + +```text +/buddy +``` + +预期: + +- 显示同一只 companion +- 不重新 hatch +- 名字和 personality 不变化 + +### 10.4 抚摸 + +输入: + +```text +/buddy pet +``` + +预期: + +- hearts 动画持续约 2.5 秒 +- 若之前为 off,则 pet 后应可见 sprite + +### 10.5 关闭 + +输入: + +```text +/buddy off +``` + +预期: + +- sprite 消失 +- 后续消息不再注入 `companion_intro` + +### 10.6 打开 + +输入: + +```text +/buddy on +``` + +预期: + +- sprite 恢复 +- 后续消息重新注入 `companion_intro` + +### 10.7 observer + +完成 observer 后测试: + +1. 输入包含 companion 名字的消息 +2. 制造一次测试失败文本 +3. 制造一次错误文本 + +预期: + +- `companionReaction` 被更新 +- bubble 出现 +- bubble 自动淡出 + +## 11. 实施顺序 + +### Phase 1 + +- 修改 `src/commands/buddy/index.ts` +- 新建 `src/commands/buddy/buddy.ts` +- 实现 `off / on / pet` + +完成标准: + +- `/buddy off` +- `/buddy on` +- `/buddy pet` + +全部可运行 + +### Phase 2 + +- 新建 `src/commands/buddy/hatch.ts` +- 新建 `src/commands/buddy/card.ts` +- 实现 `/buddy` + +完成标准: + +- 可 hatch +- 可持久化 +- 可查看卡片 + +### Phase 3 + +- 新建 `src/buddy/observer.ts` +- 修改 `src/screens/REPL.tsx` + +完成标准: + +- 本地 reaction bubble 可工作 + +### Phase 4 + +- 增加测试 +- 跑 smoke +- 删除 `global.d.ts` 中的旧 observer 声明 + +## 12. 风险点 + +### 12.1 最高风险 + +- `src/commands/buddy/index.ts` 若仍为空对象,则在 `feature('BUDDY')` 启用时命令系统是坏的 + +### 12.2 高风险 + +- 若把 `companionPetAt` 写进 config,现有 UI 链路不会正确工作 +- 若继续依赖全局 `fireCompanionObserver(...)`,后续维护仍不可控 + +### 12.3 中风险 + +- 若 hatch 同时引入模型生成,会把恢复范围扩展到 query / structured output / error fallback + +## 13. 回滚点 + +若实施中断,可按以下顺序回滚: + +1. 删除 `src/commands/buddy/buddy.ts` +2. 删除 `src/commands/buddy/hatch.ts` +3. 删除 `src/commands/buddy/card.ts` +4. 删除 `src/buddy/observer.ts` +5. 恢复 `src/commands/buddy/index.ts` 到原始状态 +6. 恢复 `src/screens/REPL.tsx` 的 observer 调用 + +现有 buddy 显示层不会受影响。 + +## 14. 第二阶段扩展建议 + +第一阶段稳定后,可以继续做: + +- hatch 使用模型生成 `name` 和 `personality` +- observer 接远程 API +- hatch 动画 JSX +- 更细致的 reaction reason 分类 +- `/buddy rename` 或更多 companion 管理命令 + +这些都不应阻塞第一阶段落地。 + +## 15. 结论 + +当前 buddy 恢复工作的本质不是“重做一个新功能”,而是“把已经存在的显示层和上下文层补成完整系统”。 + +当前仓库已经具备: + +- deterministic bones +- config schema +- AppState schema +- sprite UI +- prompt injection +- REPL 挂载点 + +因此第一阶段只需要完成三块: + +- 命令层 +- hatch / card +- observer + +按本文档实施后,buddy 将具备: + +- 可执行 slash command +- 可持久化 companion +- 可显示 sprite +- 可响应 pet +- 可静音和恢复 +- 可进行本地 bubble reaction + diff --git a/docs/claude-buddy-system/README.md b/docs/claude-buddy-system/README.md new file mode 100644 index 0000000000..1d74794eac --- /dev/null +++ b/docs/claude-buddy-system/README.md @@ -0,0 +1,127 @@ +# Claude Code Buddy System — 源码还原 + +> 从 `@anthropic-ai/claude-code` npm 包 (v2.1.89) 的 `cli.js` 反编译还原 + +## 这是什么 + +Claude Code 在 2026 年 4 月 1 日推出了一个隐藏的 **AI 电子宠物系统** — 终端里的拓麻歌子。 + +用户输入 `/buddy` 后会"孵化"一只专属宠物,它会坐在输入框旁边看你写代码,偶尔冒出气泡吐槽你的代码。 + +## 系统架构 + +``` +src/buddy/ +├── types.ts # 类型定义 & 常量 (物种/稀有度/属性/外观) +├── companion.ts # 核心模块 (PRNG/哈希/骨架生成/Config读写) +├── sprites.ts # ASCII 精灵动画 (18物种×3帧 + 帽子) +├── useBuddyNotification.ts # 可用性检查 & 预告通知 +├── buddyCommand.ts # /buddy 斜杠命令入口 +├── buddyReaction.ts # 宠物自动评论 (API调用 + 触发逻辑) +└── CompanionWidget.ts # 终端渲染组件 (React + Ink) +``` + +## 核心机制 + +### 1. 确定性宠物生成 + +每个用户的宠物是**确定性的** — 改配置也没用: + +``` +userId + "friend-2026-401" → FNV-1a hash → Mulberry32 PRNG → 逐项 roll +``` + +### 2. 18 种物种 + +| 物种 | 英文 | 特征 | +|------|------|------| +| 🦆 鸭子 | duck | `<(· )___` | +| 🪿 鹅 | goose | `(·>` | +| 🫧 果冻 | blob | 会膨胀缩小 | +| 🐱 猫 | cat | `=·ω·=` | +| 🐉 龙 | dragon | 会喷烟 | +| 🐙 章鱼 | octopus | 触手摆动 | +| 🦉 猫头鹰 | owl | 会眨眼 | +| 🐧 企鹅 | penguin | 会滑行 | +| 🐢 乌龟 | turtle | 龟壳变化 | +| 🐌 蜗牛 | snail | 留痕迹 | +| 👻 幽灵 | ghost | 飘浮波纹 | +| 🦎 六角恐龙 | axolotl | 鳃摆动 | +| 🦫 水豚 | capybara | 最大头 | +| 🌵 仙人掌 | cactus | 手臂变换 | +| 🤖 机器人 | robot | 天线闪烁 | +| 🐰 兔子 | rabbit | 耳朵抖动 | +| 🍄 蘑菇 | mushroom | 帽子变大 | +| 😺 胖猫 | chonk | 尾巴摇 | + +### 3. 稀有度系统 + +| 稀有度 | 概率 | 星级 | 基础属性 | 帽子 | +|--------|------|------|----------|------| +| Common | 60% | ★ | 5 | 无 | +| Uncommon | 25% | ★★ | 15 | 有 | +| Rare | 10% | ★★★ | 25 | 有 | +| Epic | 4% | ★★★★ | 35 | 有 | +| Legendary | 1% | ★★★★★ | 50 | 有 | + +**闪光 (Shiny)**: 任何稀有度都有 1% 概率 + +### 4. 外观系统 + +**眼睛** (6种): `·` `✦` `×` `◉` `@` `°` + +**帽子** (8种): +``` +crown: \^^^/ 皇冠 +tophat: [___] 高帽 +propeller: -+- 螺旋桨帽 +halo: ( ) 光环 +wizard: /^\ 巫师帽 +beanie: (___) 毛线帽 +tinyduck: ,> 小鸭子 +``` + +### 5. 属性系统 + +五项属性: **DEBUGGING** / **PATIENCE** / **CHAOS** / **WISDOM** / **SNARK** + +- 随机选 2 个为突出属性 (主属性大幅加成,副属性略低) +- 其余为基础 + 随机 + +### 6. AI 灵魂生成 + +孵化时调用 Haiku 模型生成: +- **名字**: 一个词,≤12字符,略带荒诞 (如 Pith, Dusker, Crumb) +- **性格**: 一句话,影响它评论代码的方式 + +### 7. 宠物评论 (Reaction) + +宠物会在以下情况冒出气泡评论: +- **测试失败** — 检测到 "X failed" / "FAIL" 等 +- **代码错误** — 检测到 "error:" / "exception" / "traceback" +- **被叫名字** — 用户在消息中提到宠物名字 +- **周期性** — 每隔一段时间 + +评论通过 API 生成: +``` +POST /api/organizations/{orgId}/claude_code/buddy_react +``` + +### 8. 系统提示注入 + +宠物激活后,会在 Claude 的系统提示中注入一段 ``, +告诉 Claude "有一个叫 {name} 的 {species} 坐在旁边, +用户叫它名字时你要让开"。 + +## 编译门控 + +Buddy 系统受三层门控保护: + +1. **编译开关**: `feature('BUDDY')` — 构建时决定代码是否包含 +2. **运行时检查**: `isBuddyLive()` — firstParty + 日期 ≥ 2026-04-01 +3. **远程标志**: `tengu_amber_flint` — GrowthBook A/B 测试 + +## 声明 + +- 源码版权归 [Anthropic](https://www.anthropic.com) 所有 +- 仅用于技术研究与学习 diff --git a/docs/claude-buddy-system/src/buddy/CompanionWidget.ts b/docs/claude-buddy-system/src/buddy/CompanionWidget.ts new file mode 100644 index 0000000000..ddf8e5a662 --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/CompanionWidget.ts @@ -0,0 +1,221 @@ +/** + * Claude Code Buddy System — Companion UI Widget (终端渲染) + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + * + * 这是 Buddy 在终端中的实际渲染组件,使用 React + Ink + * 这里还原为伪代码/逻辑描述,因为实际渲染依赖 Ink 框架 + */ + +import { RARITY_COLORS, RARITY_STARS, type Companion } from './types.js'; +import { renderSprite, getFrameCount, getEmojiFace, FRAME_INTERVAL } from './sprites.js'; + +// ─── 动画常量 ───────────────────────────────────────────── + +/** 动画帧率 (ms) — 源码: Zl8 = 500 */ +const TICK_INTERVAL = 500; + +/** Reaction 气泡显示时长 (帧数) — 源码: n$7 = 20 → 20 * 500ms = 10秒 */ +const REACTION_DURATION_TICKS = 20; + +/** Reaction 淡出开始 (最后 N 帧) — 源码: FiK = 6 */ +const FADE_START_TICKS = 6; + +/** 空闲动画序列 — 源码: miK */ +const IDLE_ANIMATION = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; +// -1 = 闭眼帧 (眼睛替换为 "-") + +/** + * 宠物抚摸 (pet) 的爱心动画帧 + * + * 源码: piK (使用 ❤ = r6.heart) + */ +const PET_HEART_FRAMES = [ + ' ❤ ❤ ', + ' ❤ ❤ ❤ ', + ' ❤ ❤ ❤ ', + '❤ ❤ ❤ ', + '· · · ', +]; + +// ─── 触发概率 ───────────────────────────────────────────── + +/** 宠物最低消息数阈值,低于此不触发 reaction — 源码: Gl8 = 100 */ +const MIN_MESSAGES_FOR_REACTION = 100; + +/** 基础 reaction 行数 — 源码: CUY = 12 */ +const BASE_REACTION_LINES = 12; + +/** 每次 name 长度的额外行数 — 源码: bUY = 2 */ +const NAME_LENGTH_BONUS = 2; + +/** 额外行数上限 — 源码: xUY = 2 */ +const EXTRA_LINES = 2; + +/** 直接称呼名字时的额外行数 — 源码: IUY = 36 */ +const ADDRESSED_BONUS = 36; + +// ─── Widget 渲染逻辑 ────────────────────────────────────── + +/** + * 计算 Reaction 触发所需的最小行数 + * + * 源码中的变量映射: diK = calculateReactionThreshold + * + * @param messageCount - 当前会话消息数 + * @param addressed - 是否直接称呼了宠物名字 + * @returns 需要等待的行数 (0 = 不触发) + */ +export function calculateReactionThreshold( + companion: Companion | undefined, + messageCount: number, + addressed: boolean, +): number { + if (!companion) return 0; + // 源码: if (getGlobalConfig().companionMuted) return 0; + if (messageCount < MIN_MESSAGES_FOR_REACTION) return 0; + + const nameLength = companion.name.length; // 源码: H1(_.name) + const addressedBonus = addressed ? ADDRESSED_BONUS : 0; + + return Math.max(BASE_REACTION_LINES, nameLength + NAME_LENGTH_BONUS) + EXTRA_LINES + addressedBonus; +} + +/** + * CompanionWidget — 主渲染组件 + * + * React (Ink) 组件,渲染在终端输入框旁边: + * 1. ASCII 精灵动画 (500ms 帧率) + * 2. 气泡对话 (reaction 文本) + * 3. 爱心动画 (pet 后) + * 4. 颜色由稀有度决定 + * + * 源码中的变量映射: i$7 = CompanionWidget + * + * 伪代码: + * ``` + * function CompanionWidget() { + * const reaction = useStore(s => s.companionReaction); + * const petAt = useStore(s => s.companionPetAt); + * const [tick, setTick] = useState(0); + * + * useEffect(() => { + * const interval = setInterval(() => setTick(t => t + 1), TICK_INTERVAL); + * return () => clearInterval(interval); + * }, []); + * + * // 自动清除过期的 reaction + * useEffect(() => { + * if (!reaction) return; + * const timeout = setTimeout(() => { + * setStore(s => ({ ...s, companionReaction: undefined })); + * }, REACTION_DURATION_TICKS * TICK_INTERVAL); + * return () => clearTimeout(timeout); + * }, [reaction]); + * + * const companion = getCompanion(); + * if (!companion || config.companionMuted) return null; + * + * const color = RARITY_COLORS[companion.rarity]; + * const fading = tick >= REACTION_DURATION_TICKS - FADE_START_TICKS; + * + * // 选择动画帧 + * const isPetting = petAt && tick - petStartTick < PET_HEART_FRAMES.length; + * let frameIdx; + * if (reaction || isPetting) { + * frameIdx = tick % getFrameCount(companion.species); + * } else { + * const idleStep = IDLE_ANIMATION[tick % IDLE_ANIMATION.length]; + * if (idleStep === -1) { + * frameIdx = 0; // 闭眼 + * } else { + * frameIdx = idleStep % getFrameCount(companion.species); + * } + * } + * + * const spriteLines = renderSprite(companion, frameIdx); + * // 闭眼时替换眼睛为 "-" + * if (IDLE_ANIMATION[tick % IDLE_ANIMATION.length] === -1) { + * spriteLines.forEach((line, i) => { + * spriteLines[i] = line.replaceAll(companion.eye, '-'); + * }); + * } + * + * return ( + * + * {isPetting && {PET_HEART_FRAMES[...]}} + * {spriteLines.map(line => {line})} + * {companion.name} + * {reaction && } + * + * ); + * } + * ``` + */ + +/** + * ReactionBubble — 气泡组件 + * + * 显示在精灵上方,带有向下的尾巴指向精灵 + * + * 源码中的变量映射: UiK = SpeechBubble + */ + +/** + * CompanionCard — 孵化/查看卡片组件 + * + * 源码中的变量映射: Qd8 = CompanionCard + * + * 渲染: + * - 精灵 (居中) + * - 稀有度星级 + * - 名字 + 物种 + * - 性格描述 + * - 五项属性条 + * + * 伪代码: + * ``` + * function CompanionCard({ companion, lastReaction, onDone }) { + * const color = RARITY_COLORS[companion.rarity]; + * const sprite = renderSprite(companion); + * const stars = RARITY_STARS[companion.rarity]; + * + * return ( + * + * + * {sprite.map(line => {line})} + * {companion.name} + * {companion.species} · {companion.rarity.toUpperCase()} {stars} + * {companion.shiny && ✨ SHINY} + * {companion.personality} + * {STAT_NAMES.map(stat => ( + * + * ))} + * + * + * ); + * } + * ``` + */ + +/** + * HatchAnimation — 孵化动画 + * + * 源码: 显示蛋的破碎动画帧序列 + * + * ``` + * 帧序列 (wz7): + * 1. 完整蛋: ╱╲ ╱╲ → 裂纹逐渐扩大 + * 2. 蛋裂开: 裂缝变大 + * 3. 蛋碎裂: 碎片散开 + * 4. 星光闪烁: · ✦ · 过渡 + * 5. 最终: 显示 CompanionCard + * ``` + */ +export const HATCH_EGG_FRAMES = [ + // 完整蛋 + { offset: 0, lines: [' __ __ ', ' / ___ \\ ', ' / / \\ \\ ', ' | / \\ | ', ' \\ ∨ / ', ' \\__∨__/ '] }, + // 裂纹 + { offset: 1, lines: [' __ __ ', ' / V V \\ ', ' / ∕ \\ \\ ', ' | ∕ \\ | ', ' \\ ∨ / ', ' \\__∨__/ '] }, + // 星光过渡 + { offset: 0, lines: [' · ✦ · ', ' · · ', ' · ✦ · ', ' ✦ ✦ ', ' · · · ', ' · ✦ · '] }, +]; diff --git a/docs/claude-buddy-system/src/buddy/buddyCommand.ts b/docs/claude-buddy-system/src/buddy/buddyCommand.ts new file mode 100644 index 0000000000..6575f5e5dc --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/buddyCommand.ts @@ -0,0 +1,205 @@ +/** + * Claude Code Buddy System — /buddy 斜杠命令 + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + * + * 源码位置: src/commands/buddy/buddy.ts + * + * 这是实际在 CLI 中运行时的 /buddy 命令入口 + * 源码中的变量映射: TCY = buddyCommand, kCY = default export + */ + +import { getCompanion, getOrCreateCompanion, companionUserId, roll, buildSoulPrompt, SOUL_SYSTEM_PROMPT } from './companion.js'; +import { RARITY_STARS, RARITY_COLORS, STAT_NAMES, type CompanionBones, type CompanionSoul } from './types.js'; +import { renderSprite, getEmojiFace } from './sprites.js'; +import { isBuddyLive } from './useBuddyNotification.js'; + +/** + * /buddy 命令定义 + * + * 源码中实际使用的子命令: + * - /buddy (无参数) = 孵化 (如果没有) 或显示 (如果已有) + * - /buddy pet = 抚摸 + * - /buddy off = 静音 + * - /buddy on = 取消静音 + * + * 注意: 最终版本的子命令与早期版本 (hatch/card/mute/unmute) 不同 + */ +const buddyCommand = { + type: 'local-jsx' as const, + name: 'buddy', + description: 'Hatch a coding companion · pet, off', + argumentHint: '[pet|off]', + + /** 仅在 isBuddyLive() 为 true 时可见 */ + get isHidden(): boolean { + return !isBuddyLive(); + }, + + /** 立即执行 (不等待 AI 回复) */ + immediate: true, + + load: () => + Promise.resolve({ + async call( + addSystemMessage: (text: string, opts?: { display: string }) => void, + context: any, + args?: string, + ) { + const config = _getConfig(); + const subcommand = args?.trim(); + + // ─── /buddy off ─── + if (subcommand === 'off') { + if (config.companionMuted !== true) { + _updateConfig({ companionMuted: true }); + } + addSystemMessage('companion muted', { display: 'system' }); + return null; + } + + // ─── /buddy on ─── + if (subcommand === 'on') { + if (config.companionMuted === true) { + _updateConfig({ companionMuted: false }); + } + addSystemMessage('companion unmuted', { display: 'system' }); + return null; + } + + // ─── 功能不可用 ─── + if (!isBuddyLive()) { + addSystemMessage('buddy is unavailable on this configuration', { display: 'system' }); + return null; + } + + // ─── /buddy pet ─── + if (subcommand === 'pet') { + const companion = getCompanion(); + if (!companion) { + addSystemMessage('no companion yet · run /buddy first', { display: 'system' }); + return null; + } + if (config.companionMuted === true) { + _updateConfig({ companionMuted: false }); + } + // 触发爱心动画: 设置 companionPetAt = Date.now() + _updateConfig({ companionPetAt: Date.now() }); + return null; + } + + // ─── /buddy (孵化或显示) ─── + const existing = getCompanion(); + if (existing) { + // 已有宠物 → 显示 CompanionCard (JSX) + // 源码: 返回 JSX 组件 + return { + type: 'local-jsx' as const, + jsx: null, // 实际是 React JSX + result: formatCompanionCard(existing), + }; + } + + // 孵化新宠物 + const userId = companionUserId(); + const { bones, inspirationSeed } = roll(userId); + + // 调用 AI 生成 name + personality + // 源码: await yFK(bones, inspirationSeed, abortSignal) + // 使用 Haiku 模型,temperature=1,JSON schema output + addSystemMessage('hatching a coding buddy…', { display: 'system' }); + addSystemMessage("it'll watch you work and occasionally have opinions", { display: 'system' }); + + try { + const soul = await generateSoulViaAI(bones, inspirationSeed); + _updateConfig({ companion: { ...soul, hatchedAt: Date.now() } }); + const companion = { ...bones, ...soul, hatchedAt: Date.now() }; + + // 返回孵化动画 + CompanionCard + return { + type: 'local-jsx' as const, + jsx: null, // 实际是 + result: formatCompanionCard(companion), + }; + } catch (err) { + // AI 生成失败,使用 fallback 名字 + const fallbackSoul: CompanionSoul = { + name: bones.species.charAt(0).toUpperCase() + bones.species.slice(1), + personality: 'Watches your code with quiet interest.', + hatchedAt: Date.now(), + }; + _updateConfig({ companion: fallbackSoul }); + const companion = { ...bones, ...fallbackSoul }; + return { + type: 'local-jsx' as const, + jsx: null, + result: formatCompanionCard(companion), + }; + } + }, + }), +}; + +// ─── 辅助函数 ───────────────────────────────────────────── + +function formatCompanionCard(companion: any): string { + const stars = RARITY_STARS[companion.rarity as keyof typeof RARITY_STARS]; + const sprite = renderSprite(companion); + const lines = [ + '', + ...sprite, + '', + ` ${companion.name}`, + ` ${companion.species} · ${companion.rarity.toUpperCase()} ${stars}${companion.shiny ? ' ✨ SHINY' : ''}`, + '', + ` ${companion.personality}`, + '', + ...STAT_NAMES.map((stat) => { + const val = companion.stats?.[stat] ?? 0; + const bar = '█'.repeat(Math.floor(val / 10)) + '░'.repeat(10 - Math.floor(val / 10)); + return ` ${stat.padEnd(12)} ${bar} ${val}`; + }), + '', + ` ${companion.name} is here · it'll chime in as you code`, + ` your buddy won't count toward your usage`, + ` say its name to get its take · /buddy pet · /buddy off`, + ]; + return lines.join('\n'); +} + +async function generateSoulViaAI( + bones: CompanionBones, + inspirationSeed: number, +): Promise { + // 源码: 调用 API 使用 Haiku 模型 + // const response = await query({ + // querySource: 'buddy_companion', + // model: getHaikuModel(), + // system: SOUL_SYSTEM_PROMPT, + // messages: [{ role: 'user', content: buildSoulPrompt(bones, inspirationSeed) }], + // output_format: { type: 'json_schema', schema: zodToJsonSchema(soulSchema) }, + // max_tokens: 512, + // temperature: 1, + // }); + // + // Schema: { name: string (1-14 chars), personality: string } + + const prompt = buildSoulPrompt(bones, inspirationSeed); + console.log('[buddy] Would call AI with prompt:', prompt); + + // Fallback + return { + name: bones.species.charAt(0).toUpperCase() + bones.species.slice(1), + personality: 'Friendly and curious', + hatchedAt: Date.now(), + }; +} + +// Config 存根 (需要对接实际 config 系统) +function _getConfig(): any { + return {}; +} +function _updateConfig(patch: any): void { + // 源码: S8((prev) => ({ ...prev, ...patch })) +} + +export default buddyCommand; diff --git a/docs/claude-buddy-system/src/buddy/buddyReaction.ts b/docs/claude-buddy-system/src/buddy/buddyReaction.ts new file mode 100644 index 0000000000..7dc60a777a --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/buddyReaction.ts @@ -0,0 +1,160 @@ +/** + * Claude Code Buddy System — Buddy Reaction (宠物自动评论) + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + * + * 这是 buddy 系统最核心的运行时行为: + * 宠物会在你编码时偶尔冒出气泡评论 + * + * 触发方式: + * 1. 自动触发: 每隔一段时间检查对话上下文,决定是否评论 + * 2. 被叫名字: 用户在消息中提到宠物名字时,一定会触发 + * + * 评论内容通过 API 调用生成: + * POST /api/organizations/{orgId}/claude_code/buddy_react + */ + +import type { Companion, Stats } from './types.js'; + +// ─── 常量 ───────────────────────────────────────────────── + +/** Reaction 检查超时 (ms) — 源码: eSY = 30000 */ +const REACTION_TIMEOUT = 30000; + +/** 最大重试次数 — 源码: qCY = 3 */ +const MAX_RETRIES = 3; + +/** 最大 reaction 文本长度 — 源码: KCY = 80 */ +const MAX_REACTION_LENGTH = 80; + +// ─── 触发检测 ───────────────────────────────────────────── + +/** 检测 Bash 工具输出中是否有测试失败 (源码: _CY) */ +const TEST_FAILURE_PATTERN = /\b[1-9]\d* (failed|failing)\b|\btests? failed\b|^FAIL(ED)?\b| ✗ | ✘ /im; + +/** 检测错误信息 (源码: zCY) */ +const ERROR_PATTERN = /\berror:|\bexception\b|\btraceback\b|\bpanicked at\b|\bfatal:|exit code [1-9]/i; + +/** + * 触发原因类型 + * + * 源码中通过不同 reason 字符串传递: + * - "test_failed" — 测试失败 + * - "error" — 代码错误 + * - "addressed" — 用户直接叫了宠物名字 + * - "periodic" — 周期性检查 (每 N 条消息) + */ +export type ReactionReason = 'test_failed' | 'error' | 'addressed' | 'periodic'; + +// ─── API 调用 ───────────────────────────────────────────── + +/** + * 请求 Buddy React API 获取宠物评论 + * + * 源码中的变量映射: Bd8 = fetchBuddyReaction + * + * 调用条件: + * 1. 必须是 firstParty 用户 + * 2. 不能是 headless 模式 + * 3. 需要有效的 OAuth token 和 organizationUuid + * + * 请求: + * POST {BASE_API_URL}/api/organizations/{orgId}/claude_code/buddy_react + * + * 请求体: + * { + * name: string, // 宠物名字 (截断到 32 字符) + * personality: string, // 性格 (截断到 200 字符) + * species: string, + * rarity: string, + * stats: Stats, + * transcript: string, // 最近对话 (截断到 5000 字符) + * reason: string, // 触发原因 + * recent: string[], // 最近消息摘要 (每条截断到 200 字符) + * addressed: boolean // 是否直接称呼了宠物名字 + * } + * + * 响应: + * { reaction: string | null } // 宠物的评论文本 + * + * 超时: 10000ms + */ +export async function fetchBuddyReaction( + companion: Companion, + transcript: string, + reason: ReactionReason, + recentMessages: string[], + addressed: boolean, + signal?: AbortSignal, +): Promise { + // 源码实现: + // if (getAuthType() !== 'firstParty') return null; + // if (isHeadless()) return null; + // + // const orgId = getGlobalConfig().oauthAccount?.organizationUuid; + // if (!orgId) return null; + // + // await refreshAuth(); + // const token = getTokenStore()?.accessToken; + // if (!token) return null; + // + // const url = `${getOauthConfig().BASE_API_URL}/api/organizations/${orgId}/claude_code/buddy_react`; + // + // const response = await axios.post(url, { + // name: companion.name.slice(0, 32), + // personality: companion.personality.slice(0, 200), + // species: companion.species, + // rarity: companion.rarity, + // stats: companion.stats, + // transcript: transcript.slice(0, 5000), + // reason, + // recent: recentMessages.map(m => m.slice(0, 200)), + // addressed, + // }, { + // headers: { + // Authorization: `Bearer ${token}`, + // 'anthropic-beta': BETA_HEADER, + // 'User-Agent': getUserAgent(), + // }, + // timeout: 10000, + // signal, + // }); + // + // return response.data.reaction?.trim() || null; + + console.log(`[buddy] fetchBuddyReaction: reason=${reason}, addressed=${addressed}`); + return null; // 需要实际 API 实现 +} + +// ─── Companion Prompt 注入 ───────────────────────────────── + +/** + * 生成注入到系统提示中的 Companion 描述 + * + * 源码中的变量映射: I44 = buildCompanionSystemReminder + * + * 当 buddy 功能激活时,这段文本会作为 system-reminder 注入到 + * Claude 的上下文中,告诉 Claude 有一个宠物伙伴存在 + * + * 大致内容: + * ``` + * # Companion + * + * A small {species} named {name} sits beside the user's input box + * and occasionally comments in a speech bubble. + * You're not {name} — it's a separate watcher. + * + * When the user addresses {name} directly (by name), its bubble + * will answer. Your job in that moment is to stay out of the way: + * respond in ONE line or less, or just answer any part of the + * message meant for you. Don't explain that you're not {name} — + * they know. Don't narrate what {name} might say — the bubble + * handles that. + * ``` + */ +export function buildCompanionSystemReminder(companion: Companion): string { + return `# Companion + +A small ${companion.species} named ${companion.name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${companion.name} — it's a separate watcher. + +When the user addresses ${companion.name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${companion.name} — they know. Don't narrate what ${companion.name} might say — the bubble handles that.`; +} diff --git a/docs/claude-buddy-system/src/buddy/companion.ts b/docs/claude-buddy-system/src/buddy/companion.ts new file mode 100644 index 0000000000..d710ff5774 --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/companion.ts @@ -0,0 +1,367 @@ +/** + * Claude Code Buddy System — Companion 核心模块 + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + * + * 核心职责: + * 1. 确定性宠物生成 (userId + salt → FNV-1a → Mulberry32 PRNG → CompanionBones) + * 2. 配置读写 (getCompanion / saveCompanion) + * 3. AI 灵魂生成 (调用 LLM 生成 name + personality) + */ + +import { + type Companion, + type CompanionBones, + type CompanionSoul, + EYES, + HATS, + type Hat, + type Rarity, + RARITY_BASE_STATS, + RARITY_NAMES, + RARITY_WEIGHTS, + SPECIES, + STAT_NAMES, + type Stats, +} from './types.js'; + +// ─── 常量 ───────────────────────────────────────────────── + +/** 固定盐值,与 userId 拼接后哈希,确保每人只得到一只固定宠物 */ +const SALT = 'friend-2026-401'; + +/** AI 名字生成的灵感词库 (136 个词) */ +const INSPIRATION_WORDS = [ + 'thunder', 'biscuit', 'void', 'accordion', 'moss', 'velvet', 'rust', 'pickle', + 'crumb', 'whisper', 'gravy', 'frost', 'ember', 'soup', 'marble', 'thorn', + 'honey', 'static', 'copper', 'dusk', 'sprocket', 'bramble', 'cinder', 'wobble', + 'drizzle', 'flint', 'tinsel', 'murmur', 'clatter', 'gloom', 'nectar', 'quartz', + 'shingle', 'tremor', 'umber', 'waffle', 'zephyr', 'bristle', 'dapple', 'fennel', + 'gristle', 'huddle', 'kettle', 'lumen', 'mottle', 'nuzzle', 'pebble', 'quiver', + 'ripple', 'sable', 'thistle', 'vellum', 'wicker', 'yonder', 'bauble', 'cobble', + 'doily', 'fickle', 'gambit', 'hubris', 'jostle', 'knoll', 'larder', 'mantle', + 'nimbus', 'oracle', 'plinth', 'quorum', 'relic', 'spindle', 'trellis', 'urchin', + 'vortex', 'warble', 'xenon', 'yoke', 'zenith', 'alcove', 'brogue', 'chisel', + 'dirge', 'epoch', 'fathom', 'glint', 'hearth', 'inkwell', 'jetsam', 'kiln', + 'lattice', 'mirth', 'nook', 'obelisk', 'parsnip', 'quill', 'rune', 'sconce', + 'tallow', 'umbra', 'verve', 'wisp', 'yawn', 'apex', 'brine', 'crag', + 'dregs', 'etch', 'flume', 'gable', 'husk', 'ingot', 'jamb', 'knurl', + 'loam', 'mote', 'nacre', 'ogle', 'prong', 'quip', 'rind', 'slat', + 'tuft', 'vane', 'welt', 'yarn', 'bane', 'clove', 'dross', 'eave', + 'fern', 'grit', 'hive', 'jade', 'keel', 'lilt', 'muse', 'nape', + 'omen', 'pith', 'rook', 'silt', 'tome', 'urge', 'vex', 'wane', 'yew', 'zest', +]; + +/** AI 名字生成的备选前缀词 */ +const FALLBACK_NAMES = ['Crumpet', 'Soup', 'Pickle', 'Biscuit', 'Moth', 'Gravy']; + +// ─── PRNG: Mulberry32 ───────────────────────────────────── + +/** + * Mulberry32 — 确定性 32-bit PRNG + * 输入 seed (uint32),返回一个函数,每次调用返回 [0, 1) 浮点数 + * + * 源码中的变量映射: GV_ = mulberry32 + */ +export function mulberry32(seed: number): () => number { + let s = seed >>> 0; + return function () { + s |= 0; + s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ─── Hash: FNV-1a ───────────────────────────────────────── + +/** + * FNV-1a 哈希 (32-bit) + * 如果运行在 Bun 环境下,使用 Bun.hash();否则用纯 JS 实现 + * + * 源码中的变量映射: vV_ = fnv1a + */ +export function fnv1a(str: string): number { + if (typeof Bun !== 'undefined') { + // Bun 环境下使用原生哈希并截断为 32 位 + return Number(BigInt((Bun as any).hash(str)) & 0xffffffffn); + } + // 纯 JS FNV-1a + let hash = 2166136261; // FNV offset basis + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, 16777619); // FNV prime + } + return hash >>> 0; +} + +// ─── 辅助函数 ───────────────────────────────────────────── + +/** 从数组中随机选一个元素 (源码: yT6) */ +function pick(rng: () => number, arr: readonly T[]): T { + return arr[Math.floor(rng() * arr.length)]; +} + +/** 加权随机选择稀有度 (源码: TV_) */ +function rollRarity(rng: () => number): Rarity { + const totalWeight = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0); + let roll = rng() * totalWeight; + for (const rarity of RARITY_NAMES) { + roll -= RARITY_WEIGHTS[rarity]; + if (roll < 0) return rarity; + } + return 'common'; +} + +/** 生成属性值 (源码: VV_) */ +function rollStats(rng: () => number, rarity: Rarity): Stats { + const base = RARITY_BASE_STATS[rarity]; + + // 选两个不同的突出属性 + const primary = pick(rng, STAT_NAMES); + let secondary = pick(rng, STAT_NAMES); + while (secondary === primary) { + secondary = pick(rng, STAT_NAMES); + } + + const stats = {} as Stats; + for (const name of STAT_NAMES) { + if (name === primary) { + // 主属性: 大幅加成 + stats[name] = Math.min(100, base + 50 + Math.floor(rng() * 30)); + } else if (name === secondary) { + // 副属性: 略低 + stats[name] = Math.max(1, base - 10 + Math.floor(rng() * 15)); + } else { + // 其他: 基础 + 随机 + stats[name] = base + Math.floor(rng() * 40); + } + } + return stats; +} + +/** 从 seed 选取灵感词 (源码: wCY) */ +function pickInspirationWords(seed: number, count: number): string[] { + let s = seed >>> 0; + const indices = new Set(); + while (indices.size < count) { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + indices.add(s % INSPIRATION_WORDS.length); + } + return [...indices].map((i) => INSPIRATION_WORDS[i]); +} + +// ─── 核心: roll() ───────────────────────────────────────── + +export interface RollResult { + bones: CompanionBones; + inspirationSeed: number; +} + +/** + * 确定性宠物骨架生成 + * + * 流程: userId + SALT → FNV-1a hash → Mulberry32 PRNG → 逐项 roll + * + * 源码中的变量映射: yV_ = roll + */ +export function roll(userId: string): RollResult { + const key = userId + SALT; + const rng = mulberry32(fnv1a(key)); + + const rarity = rollRarity(rng); + + const bones: CompanionBones = { + rarity, + species: pick(rng, SPECIES), + eye: pick(rng, EYES), + hat: rarity === 'common' ? 'none' : pick(rng, HATS), + shiny: rng() < 0.01, // 1% 闪光概率 + stats: rollStats(rng, rarity), + }; + + return { + bones, + inspirationSeed: Math.floor(rng() * 1e9), + }; +} + +// ─── 缓存 ───────────────────────────────────────────────── + +/** roll 结果缓存 (源码: LR1) */ +let rollCache: { key: string; value: RollResult } | null = null; + +/** 带缓存的 roll (源码: hR1) */ +function cachedRoll(userId: string): RollResult { + const key = userId + SALT; + if (rollCache?.key === key) return rollCache.value; + const result = roll(userId); + rollCache = { key, value: result }; + return result; +} + +// ─── Config 读写 ─────────────────────────────────────────── + +/** + * 获取当前用户 ID + * 优先使用 OAuth accountUuid,否则 userID,最后 fallback "anon" + * + * 源码中的变量映射: RR1 = companionUserId + */ +export function companionUserId(): string { + // 源码: j8() = getGlobalConfig() + // return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'; + // + // 在还原版中简化为从环境读取: + return process.env.CLAUDE_USER_ID ?? 'anon'; +} + +/** + * 获取已孵化的 Companion (如果有) + * 合并 config 中的 soul 和 roll 生成的 bones + * + * 源码中的变量映射: vC = getCompanion + */ +export function getCompanion(): Companion | undefined { + // 源码: const soul = getGlobalConfig().companion; + // 这里简化为从环境/config读取 + const soul = _readCompanionFromConfig(); + if (!soul) return undefined; + const { bones } = cachedRoll(companionUserId()); + return { ...soul, ...bones }; +} + +/** + * 孵化并保存 Companion + * + * 流程: + * 1. roll bones + * 2. AI 生成 name + personality (调用 LLM) + * 3. 保存 soul 到 config + * 4. 返回完整 Companion + * + * 源码中的变量映射: vCY = hatchCompanion + */ +export async function getOrCreateCompanion( + generateSoul?: (bones: CompanionBones, seed: number) => Promise, +): Promise { + const userId = companionUserId(); + const { bones, inspirationSeed } = cachedRoll(userId); + + let soul: CompanionSoul; + if (generateSoul) { + soul = await generateSoul(bones, inspirationSeed); + } else { + // Fallback: 无 AI 时使用默认名字 + soul = { + name: bones.species.charAt(0).toUpperCase() + bones.species.slice(1), + personality: 'Friendly and curious', + hatchedAt: Date.now(), + }; + } + + _saveCompanionToConfig(soul); + return { ...bones, ...soul }; +} + +// ─── AI Soul 生成 ────────────────────────────────────────── + +/** 系统提示词 (源码: ACY) */ +export const SOUL_SYSTEM_PROMPT = `You generate coding companions — small creatures that live in a developer's terminal and occasionally comment on their work. + +Given a rarity, species, stats, and a handful of inspiration words, invent: +- A name: ONE word, max 12 characters. Memorable, slightly absurd. No titles, no "the X", no epithets. Think pet name, not NPC name. The inspiration words are loose anchors — riff on one, mash two syllables, or just use the vibe. Examples: Pith, Dusker, Crumb, Brogue, Sprocket. +- A one-sentence personality (specific, funny, a quirk that affects how they'd comment on code — should feel consistent with the stats) + +Higher rarity = weirder, more specific, more memorable. A legendary should be genuinely strange. +Don't repeat yourself — every companion should feel distinct.`; + +/** + * 构建 AI soul 生成的用户消息 + * + * 源码中的变量映射: yFK = generateSoul + */ +export function buildSoulPrompt(bones: CompanionBones, inspirationSeed: number): string { + const words = pickInspirationWords(inspirationSeed, 4); + const statsLine = STAT_NAMES.map((s) => `${s}:${bones.stats[s]}`).join(' '); + + return [ + `Generate a companion.`, + `Species: ${bones.species}`, + `Rarity: ${bones.rarity}`, + `Stats: ${statsLine}`, + `Inspiration: ${words.join(', ')}`, + `Make it memorable and distinct.`, + ].join('\n'); +} + +// ─── Buddy API (远程 Reaction) ───────────────────────────── + +/** + * 调用 buddy_react API 获取宠物对代码的评论 + * + * POST /api/organizations/{orgId}/claude_code/buddy_react + * + * 源码中的变量映射: Bd8 = fetchBuddyReaction + * + * 请求体: + * name, personality, species, rarity, stats: 宠物信息 + * transcript: 最近对话 (截断到 5000 字符) + * reason: 触发原因 + * recent: 最近消息 (每条截断到 200 字符) + * addressed: 是否直接称呼了宠物名字 + */ +export interface BuddyReactPayload { + name: string; + personality: string; + species: string; + rarity: string; + stats: Stats; + transcript: string; + reason: string; + recent: string[]; + addressed: boolean; +} + +// ─── Config 持久化存根 ──────────────────────────────────── + +/** 从 config 读取 companion soul (需要对接实际的 config 系统) */ +function _readCompanionFromConfig(): CompanionSoul | undefined { + // 源码: return getGlobalConfig().companion + // 实际实现需要读取 ~/.claude/.claude.json 中的 companion 字段 + try { + const fs = require('fs'); + const path = require('path'); + const configPath = path.join( + process.env.HOME || process.env.USERPROFILE || '', + '.claude', + '.claude.json', + ); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + return config.companion; + } catch { + return undefined; + } +} + +/** 保存 companion soul 到 config (需要对接实际的 config 系统) */ +function _saveCompanionToConfig(soul: CompanionSoul): void { + // 源码: saveGlobalConfig({ ...getGlobalConfig(), companion: soul }) + try { + const fs = require('fs'); + const path = require('path'); + const configPath = path.join( + process.env.HOME || process.env.USERPROFILE || '', + '.claude', + '.claude.json', + ); + let config: any = {}; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch {} + config.companion = soul; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + } catch { + // silent fail + } +} diff --git a/docs/claude-buddy-system/src/buddy/sprites.ts b/docs/claude-buddy-system/src/buddy/sprites.ts new file mode 100644 index 0000000000..099037c113 --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/sprites.ts @@ -0,0 +1,194 @@ +/** + * Claude Code Buddy System — ASCII Sprite 渲染 + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + * + * 每个物种有 3 帧动画,{E} 占位符在渲染时替换为实际眼睛字符 + * 每帧 5 行高,约 12 字符宽 + * + * 源码中的变量映射: RFK = SPRITE_FRAMES + */ + +import type { Species, Eye, Hat } from './types.js'; + +/** + * 物种 → 动画帧数组 + * 每帧是 string[](5 行),{E} 是眼睛占位符 + */ +export const SPRITE_FRAMES: Record = { + duck: [ + [' ', ' __ ', ' <({E} )___ ', ' ( ._> ', " `--´ "], + [' ', ' __ ', ' <({E} )___ ', ' ( ._> ', " `--´~ "], + [' ', ' __ ', ' <({E} )___ ', ' ( .__> ', " `--´ "], + ], + goose: [ + [' ', ' ({E}> ', ' || ', ' _(__)_ ', ' ^^^^ '], + [' ', ' ({E}> ', ' || ', ' _(__)_ ', ' ^^^^ '], + [' ', ' ({E}>> ', ' || ', ' _(__)_ ', ' ^^^^ '], + ], + blob: [ + [' ', ' .----. ', ' ( {E} {E} ) ', ' ( ) ', " `----´ "], + [' ', ' .------. ', ' ( {E} {E} ) ', ' ( ) ', " `------´ "], + [' ', ' .--. ', ' ({E} {E}) ', ' ( ) ', " `--´ "], + ], + cat: [ + [' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( ω ) ', ' (")_(") '], + [' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( ω ) ', ' (")_(")~ '], + [' ', ' /\\-/\\ ', ' ( {E} {E}) ', ' ( ω ) ', ' (")_(") '], + ], + dragon: [ + [' ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ~~ ) ', " `-vvvv-´ "], + [' ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ) ', " `-vvvv-´ "], + [' ~ ~ ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ~~ ) ', " `-vvvv-´ "], + ], + octopus: [ + [' ', ' .----. ', ' ( {E} {E} ) ', ' (______) ', ' /\\/\\/\\/\\ '], + [' ', ' .----. ', ' ( {E} {E} ) ', ' (______) ', ' \\/\\/\\/\\/ '], + [' o ', ' .----. ', ' ( {E} {E} ) ', ' (______) ', ' /\\/\\/\\/\\ '], + ], + owl: [ + [' ', ' /\\ /\\ ', ' (({E})({E})) ', ' ( >< ) ', " `----´ "], + [' ', ' /\\ /\\ ', ' (({E})({E})) ', ' ( >< ) ', ' .----. '], + [' ', ' /\\ /\\ ', ' (({E})(-)) ', ' ( >< ) ', " `----´ "], + ], + penguin: [ + [' ', ' .---. ', ' ({E}>{E}) ', ' /( )\\ ', " `---´ "], + [' ', ' .---. ', ' ({E}>{E}) ', ' |( )| ', " `---´ "], + [' .---. ', ' ({E}>{E}) ', ' /( )\\ ', " `---´ ", ' ~ ~ '], + ], + turtle: [ + [' ', ' _,--._ ', ' ( {E} {E} ) ', ' /[______]\\ ', ' `` `` '], + [' ', ' _,--._ ', ' ( {E} {E} ) ', ' /[______]\\ ', ' `` `` '], + [' ', ' _,--._ ', ' ( {E} {E} ) ', ' /[======]\\ ', ' `` `` '], + ], + snail: [ + [' ', ' {E} .--. ', ' \\ ( @ ) ', " \\_`--´ ", ' ~~~~~~~ '], + [' ', ' {E} .--. ', ' | ( @ ) ', " \\_`--´ ", ' ~~~~~~~ '], + [' ', ' {E} .--. ', ' \\ ( @ ) ', " \\_`--´ ", ' ~~~~~~ '], + ], + ghost: [ + [' ', ' .----. ', ' / {E} {E} \\ ', ' | | ', ' ~`~``~`~ '], + [' ', ' .----. ', ' / {E} {E} \\ ', ' | | ', " `~`~~`~` "], + [' ~ ~ ', ' .----. ', ' / {E} {E} \\ ', ' | | ', " ~~`~~`~~ "], + ], + axolotl: [ + [' ', '}~(______)~{', '}~({E} .. {E})~{', ' ( .--. ) ', ' (_/ \\_) '], + [' ', '~}(______){~', '~}({E} .. {E}){~', ' ( .--. ) ', ' (_/ \\_) '], + [' ', '}~(______)~{', '}~({E} .. {E})~{', ' ( -- ) ', ' ~_/ \\_~ '], + ], + capybara: [ + [' ', ' n______n ', ' ( {E} {E} ) ', ' ( oo ) ', " `------´ "], + [' ', ' n______n ', ' ( {E} {E} ) ', ' ( Oo ) ', " `------´ "], + [' ~ ~ ', ' u______n ', ' ( {E} {E} ) ', ' ( oo ) ', " `------´ "], + ], + cactus: [ + [' ', ' n ____ n ', ' | |{E} {E}| | ', ' |_| |_| ', ' | | '], + [' ', ' ____ ', ' n |{E} {E}| n ', ' |_| |_| ', ' | | '], + [' n n ', ' | ____ | ', ' | |{E} {E}| | ', ' |_| |_| ', ' | | '], + ], + robot: [ + [' ', ' .[||]. ', ' [ {E} {E} ] ', ' [ ==== ] ', " `------´ "], + [' ', ' .[||]. ', ' [ {E} {E} ] ', ' [ -==- ] ', " `------´ "], + [' * ', ' .[||]. ', ' [ {E} {E} ] ', ' [ ==== ] ', " `------´ "], + ], + rabbit: [ + [' ', ' (\\__/) ', ' ( {E} {E} ) ', ' =( .. )= ', ' (")__(") '], + [' ', ' (|__/) ', ' ( {E} {E} ) ', ' =( .. )= ', ' (")__(") '], + // 第三帧与第一帧略有变化 + [' ', ' (\\__/) ', ' ( {E} {E} ) ', ' =( .. )= ', ' (")__(") '], + ], + mushroom: [ + [' ', ' .----. ', ' / {E} {E} \\ ', " `------´ ", ' | | '], + [' ', ' .------. ', ' / {E} {E} \\ ', " `------´ ", ' | | '], + [' ', ' .----. ', ' / {E} {E} \\ ', " `------´ ", ' |~~| '], + ], + chonk: [ + [' ', ' /\\ /\\ ', ' ( {E} {E} ) ', ' ( .. ) ', " `------´ "], + [' ', ' /\\ /\\ ', ' ( {E} {E} ) ', ' ( .. ) ', " `------´~ "], + // 第三帧 + [' ', ' /\\ /\\ ', ' ( {E} {E} ) ', ' ( .. ) ', " `------´ "], + ], +}; + +/** 帽子 → ASCII 字符串 (替换精灵第一行空行) */ +export const HAT_SPRITES: Record = { + none: '', + crown: ' \\^^^/ ', + tophat: ' [___] ', + propeller: ' -+- ', + halo: ' ( ) ', + wizard: ' /^\\ ', + beanie: ' (___) ', + tinyduck: ' ,> ', +}; + +/** 动画帧率 (ms) */ +export const FRAME_INTERVAL = 500; + +// ─── 渲染函数 ───────────────────────────────────────────── + +/** + * 渲染一帧精灵 + * + * 源码中的变量映射: Ud8 = renderSprite + * + * @param companion - 需要 species, eye, hat 字段 + * @param frameIndex - 动画帧索引 (自动取模) + * @returns 渲染后的行数组 + */ +export function renderSprite( + companion: { species: Species; eye: Eye; hat: Hat }, + frameIndex: number = 0, +): string[] { + const frames = SPRITE_FRAMES[companion.species]; + const frame = frames[frameIndex % frames.length]; + + // 替换 {E} 占位符为实际眼睛字符 + const lines = [...frame.map((line) => line.replaceAll('{E}', companion.eye))]; + + // 如果有帽子且第一行为空白,替换为帽子 + if (companion.hat !== 'none' && !lines[0].trim()) { + lines[0] = HAT_SPRITES[companion.hat]; + } + + // 如果第一行仍为空白,且所有帧的第一行都为空白,删除它 + if (!lines[0].trim() && frames.every((f) => !f[0].trim())) { + lines.shift(); + } + + return lines; +} + +/** + * 获取物种的总帧数 + * + * 源码中的变量映射: SFK = getFrameCount + */ +export function getFrameCount(species: Species): number { + return SPRITE_FRAMES[species].length; +} + +/** + * 获取窄终端下的表情文字脸 (fallback) + * + * 源码中的变量映射: CFK = getEmojiFace + */ +export function getEmojiFace(companion: { species: Species; eye: Eye }): string { + const e = companion.eye; + switch (companion.species) { + case 'duck': + case 'goose': + return `(${e}>`; + case 'cat': + return `=${e}ω${e}=`; + case 'owl': + return `(${e})(${e})`; + case 'rabbit': + return `(${e}..${e})`; + case 'mushroom': + return `|${e} ${e}|`; + case 'chonk': + return `(${e}.${e})`; + default: + return `(${e} ${e})`; + } +} diff --git a/docs/claude-buddy-system/src/buddy/types.ts b/docs/claude-buddy-system/src/buddy/types.ts new file mode 100644 index 0000000000..b0ebf13f17 --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/types.ts @@ -0,0 +1,117 @@ +/** + * Claude Code Buddy System — Types & Constants + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + */ + +// ─── 稀有度 ─────────────────────────────────────────────── + +export const RARITY_NAMES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const; +export type Rarity = (typeof RARITY_NAMES)[number]; + +/** 稀有度权重(总和 100) */ +export const RARITY_WEIGHTS: Record = { + common: 60, + uncommon: 25, + rare: 10, + epic: 4, + legendary: 1, +}; + +/** 稀有度 → 星级显示 */ +export const RARITY_STARS: Record = { + common: '★', + uncommon: '★★', + rare: '★★★', + epic: '★★★★', + legendary: '★★★★★', +}; + +/** 稀有度 → 显示颜色 (Ink color token) */ +export const RARITY_COLORS: Record = { + common: 'inactive', + uncommon: 'success', + rare: 'permission', + epic: 'autoAccept', + legendary: 'warning', +}; + +/** 稀有度 → 基础属性值 */ +export const RARITY_BASE_STATS: Record = { + common: 5, + uncommon: 15, + rare: 25, + epic: 35, + legendary: 50, +}; + +// ─── 物种 ───────────────────────────────────────────────── + +export const SPECIES = [ + 'duck', + 'goose', + 'blob', + 'cat', + 'dragon', + 'octopus', + 'owl', + 'penguin', + 'turtle', + 'snail', + 'ghost', + 'axolotl', + 'capybara', + 'cactus', + 'robot', + 'rabbit', + 'mushroom', + 'chonk', +] as const; +export type Species = (typeof SPECIES)[number]; + +// ─── 外观 ───────────────────────────────────────────────── + +/** 眼睛样式 (6种) */ +export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const; +export type Eye = (typeof EYES)[number]; + +/** 帽子 (8种,common 稀有度无帽子) */ +export const HATS = [ + 'none', + 'crown', + 'tophat', + 'propeller', + 'halo', + 'wizard', + 'beanie', + 'tinyduck', +] as const; +export type Hat = (typeof HATS)[number]; + +// ─── 属性 ───────────────────────────────────────────────── + +export const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const; +export type StatName = (typeof STAT_NAMES)[number]; + +export type Stats = Record; + +// ─── 复合类型 ───────────────────────────────────────────── + +/** roll() 生成的确定性骨架 (不含 AI 生成的 name/personality) */ +export interface CompanionBones { + rarity: Rarity; + species: Species; + eye: Eye; + hat: Hat; + shiny: boolean; + stats: Stats; +} + +/** 由 AI 生成的 "灵魂" (name + personality),持久化到 config */ +export interface CompanionSoul { + name: string; + personality: string; + hatchedAt: number; +} + +/** 完整的 Companion = Bones + Soul */ +export interface Companion extends CompanionBones, CompanionSoul {} diff --git a/docs/claude-buddy-system/src/buddy/useBuddyNotification.ts b/docs/claude-buddy-system/src/buddy/useBuddyNotification.ts new file mode 100644 index 0000000000..1c7fe1599c --- /dev/null +++ b/docs/claude-buddy-system/src/buddy/useBuddyNotification.ts @@ -0,0 +1,62 @@ +/** + * Claude Code Buddy System — Buddy 通知 & 可用性检查 + * 从 @anthropic-ai/claude-code cli.js (v2.1.89) 反编译还原 + * + * 源码位置: src/buddy/useBuddyNotification.ts + */ + +/** + * 判断 Buddy 系统是否可用 + * + * 源码中的变量映射: Fd8 = isBuddyLive + * + * 条件: + * 1. 必须是 firstParty (OAuth 登录的 claude.ai 用户) + * 2. 不能是 headless 模式 + * 3. 日期限制: 2026 年 4 月 1 日之后 (April Fools 彩蛋,但保留至今) + * 源码: q.getFullYear() > 2026 || (q.getFullYear() === 2026 && q.getMonth() >= 3) + * 注意: getMonth() 是 0-based,3 = April + */ +export function isBuddyLive(): boolean { + // 源码: if (getAuthType() !== 'firstParty') return false; + // 源码: if (isHeadless()) return false; + const now = new Date(); + return now.getFullYear() > 2026 || (now.getFullYear() === 2026 && now.getMonth() >= 3); +} + +/** + * Buddy 预告通知 Hook + * + * 源码中的变量映射: LFK = useBuddyTeaser + * + * 如果用户还没有 companion 且 buddy 功能可用, + * 显示一个 15 秒的 "/buddy" 彩虹文字通知提示用户去孵化 + */ +export function useBuddyTeaser(): void { + // 源码实现 (React Hook): + // useEffect(() => { + // if (getGlobalConfig().companion || !isBuddyLive()) return; + // const cleanup = addNotification({ + // key: 'buddy-teaser', + // jsx: , + // priority: 'immediate', + // timeoutMs: 15000, + // }); + // return () => removeNotification('buddy-teaser'); + // }, [addNotification, removeNotification]); +} + +/** + * 检测消息中是否包含 /buddy 命令 + * + * 源码中的变量映射: hFK = findBuddyMentions + */ +export function findBuddyMentions(text: string): Array<{ start: number; end: number }> { + const matches: Array<{ start: number; end: number }> = []; + const regex = /\/buddy\b/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + matches.push({ start: match.index, end: match.index + match[0].length }); + } + return matches; +} diff --git a/scripts/dev.ts b/scripts/dev.ts index 5a9ec8fa5d..70c8aec1b6 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -13,8 +13,21 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [ `${k}:${v}`, ]); +// Bun --feature flags: enable feature() gates at runtime. +// Default features enabled in dev mode. +const DEFAULT_FEATURES = ["BUDDY"]; + +// Any env var matching FEATURE_=1 will also enable that feature. +// e.g. FEATURE_PROACTIVE=1 bun run dev +const envFeatures = Object.entries(process.env) + .filter(([k]) => k.startsWith("FEATURE_")) + .map(([k]) => k.replace("FEATURE_", "")); + +const allFeatures = [...new Set([...DEFAULT_FEATURES, ...envFeatures])]; +const featureArgs = allFeatures.flatMap((name) => ["--feature", name]); + const result = Bun.spawnSync( - ["bun", "run", ...defineArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)], + ["bun", "run", ...defineArgs, ...featureArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)], { stdio: ["inherit", "inherit", "inherit"] }, ); diff --git a/src/buddy/__tests__/observer.test.ts b/src/buddy/__tests__/observer.test.ts new file mode 100644 index 0000000000..fe7444eb77 --- /dev/null +++ b/src/buddy/__tests__/observer.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test, beforeEach } from 'bun:test' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' + +const { + fireCompanionObserver, + detectReactionReason, + buildLocalReaction, +} = await import('../observer.js') + +// Reset config before each test +function resetConfig() { + saveGlobalConfig(() => ({ + ...getGlobalConfig(), + companion: undefined as any, + companionMuted: undefined as any, + })) +} + +function setCompanion(name = 'Miso') { + saveGlobalConfig(c => ({ + ...c, + companion: { name, personality: 'test', hatchedAt: 1 }, + companionMuted: false, + })) +} + +// Helper to build a minimal user message +function userMsg(text: string) { + return { type: 'user', param: { text, type: 'text' } } +} + +// Helper to build a minimal assistant message with text content +function assistantMsg(text: string) { + return { + type: 'assistant', + message: { + content: [{ type: 'text', text }], + }, + } +} + +describe('detectReactionReason', () => { + test('detects when user addresses companion by name', () => { + const messages = [userMsg('Hey Miso, what do you think?')] + expect(detectReactionReason(messages, 'Miso')).toBe('addressed') + }) + + test('name matching is case-insensitive', () => { + const messages = [userMsg('hey miso')] + expect(detectReactionReason(messages, 'Miso')).toBe('addressed') + }) + + test('detects test failure in assistant output', () => { + const messages = [ + userMsg('run the tests'), + assistantMsg('3 tests failed'), + ] + expect(detectReactionReason(messages, 'Miso')).toBe('test_failed') + }) + + test('detects FAIL keyword', () => { + const messages = [assistantMsg('FAIL src/utils/foo.test.ts')] + expect(detectReactionReason(messages, 'Buddy')).toBe('test_failed') + }) + + test('detects error in assistant output', () => { + const messages = [ + userMsg('build it'), + assistantMsg('TypeError: cannot read property of undefined\nerror: Build failed'), + ] + expect(detectReactionReason(messages, 'Buddy')).toBe('error') + }) + + test('detects traceback', () => { + const messages = [assistantMsg('Traceback (most recent call last):')] + expect(detectReactionReason(messages, 'Buddy')).toBe('error') + }) + + test('detects exit code', () => { + const messages = [assistantMsg('Process exited with exit code 1')] + expect(detectReactionReason(messages, 'Buddy')).toBe('error') + }) + + test('returns null for normal messages', () => { + const messages = [ + userMsg('please fix the login page'), + assistantMsg('Sure, I updated the component.'), + ] + expect(detectReactionReason(messages, 'Buddy')).toBeNull() + }) +}) + +describe('buildLocalReaction', () => { + test('returns a string for addressed', () => { + const r = buildLocalReaction('addressed', 42) + expect(typeof r).toBe('string') + expect(r.length).toBeGreaterThan(0) + expect(r.length).toBeLessThanOrEqual(80) + }) + + test('returns a string for test_failed', () => { + const r = buildLocalReaction('test_failed', 7) + expect(typeof r).toBe('string') + expect(r.length).toBeGreaterThan(0) + }) + + test('returns a string for error', () => { + const r = buildLocalReaction('error', 99) + expect(typeof r).toBe('string') + expect(r.length).toBeGreaterThan(0) + }) + + test('is deterministic for same seed', () => { + expect(buildLocalReaction('addressed', 42)).toBe( + buildLocalReaction('addressed', 42), + ) + }) +}) + +describe('fireCompanionObserver', () => { + beforeEach(() => { + resetConfig() + }) + + test('returns undefined when no companion', async () => { + let result: string | undefined = 'initial' + await fireCompanionObserver( + [userMsg('hello')], + (r: string | undefined) => { result = r }, + ) + expect(result).toBeUndefined() + }) + + test('returns undefined when muted', async () => { + setCompanion('Miso') + saveGlobalConfig(c => ({ ...c, companionMuted: true })) + + let result: string | undefined = 'initial' + await fireCompanionObserver( + [userMsg('hey Miso')], + (r: string | undefined) => { result = r }, + ) + expect(result).toBeUndefined() + }) + + test('triggers on name mention', async () => { + setCompanion('Miso') + + let result: string | undefined + await fireCompanionObserver( + [userMsg('What do you think, Miso?')], + (r: string | undefined) => { result = r }, + ) + expect(result).toBeDefined() + expect(typeof result).toBe('string') + expect(result!.length).toBeGreaterThan(0) + }) + + test('triggers on test failure text', async () => { + setCompanion('Buddy') + + let result: string | undefined + await fireCompanionObserver( + [userMsg('run tests'), assistantMsg('5 tests failed')], + (r: string | undefined) => { result = r }, + ) + expect(result).toBeDefined() + expect(typeof result).toBe('string') + }) + + test('triggers on error text', async () => { + setCompanion('Buddy') + + let result: string | undefined + await fireCompanionObserver( + [userMsg('build'), assistantMsg('fatal: compilation error')], + (r: string | undefined) => { result = r }, + ) + expect(result).toBeDefined() + expect(typeof result).toBe('string') + }) + + test('returns undefined for normal conversation', async () => { + setCompanion('Buddy') + + let result: string | undefined = 'initial' + await fireCompanionObserver( + [userMsg('fix the button'), assistantMsg('Done, updated the CSS.')], + (r: string | undefined) => { result = r }, + ) + expect(result).toBeUndefined() + }) +}) diff --git a/src/buddy/companion.ts b/src/buddy/companion.ts index 09c383861e..a0fa798cd1 100644 --- a/src/buddy/companion.ts +++ b/src/buddy/companion.ts @@ -116,18 +116,21 @@ export function rollWithSeed(seed: string): Roll { return rollFrom(mulberry32(hashString(seed))) } +export function generateSeed(): string { + return `rehatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + export function companionUserId(): string { const config = getGlobalConfig() return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' } -// Regenerate bones from userId, merge with stored soul. Bones never persist -// so species renames and SPECIES-array edits can't break stored companions, -// and editing config.companion can't fake a rarity. +// Regenerate bones from seed or userId, merge with stored soul. export function getCompanion(): Companion | undefined { const stored = getGlobalConfig().companion if (!stored) return undefined - const { bones } = roll(companionUserId()) + const seed = stored.seed ?? companionUserId() + const { bones } = rollWithSeed(seed) // bones last so stale bones fields in old-format configs get overridden return { ...stored, ...bones } } diff --git a/src/buddy/observer.ts b/src/buddy/observer.ts new file mode 100644 index 0000000000..eb73c6a05d --- /dev/null +++ b/src/buddy/observer.ts @@ -0,0 +1,167 @@ +/** + * Companion observer — local Phase 1 implementation. + * + * Detects triggers in conversation messages and generates template-based + * reactions. Replaces the global `fireCompanionObserver` declaration from + * global.d.ts with a concrete, testable module. + * + * Self-registers on globalThis so REPL.tsx's existing bare call works + * without import changes. + */ +import { getCompanion } from './companion.js' +import { getGlobalConfig } from '../utils/config.js' + +// ─── Trigger patterns ──────────────────────────────────────── + +const TEST_FAILURE_RE = + /\b[1-9]\d* (failed|failing)\b|\btests? failed\b|^FAIL(ED)?\b| ✗ | ✘ /im + +const ERROR_RE = + /\berror:|\bexception\b|\btraceback\b|\bpanicked at\b|\bfatal:|exit code [1-9]/i + +export type ReactionReason = 'addressed' | 'test_failed' | 'error' + +// ─── Reaction templates ────────────────────────────────────── + +const ADDRESSED_REACTIONS = [ + "hmm, let me think about that…", + "oh, you're talking to me?", + "I have thoughts. not good ones, but thoughts.", + "you rang?", + "*perks up*", +] + +const TEST_FAILED_REACTIONS = [ + "that test had it coming.", + "oof. red is a strong color choice.", + "have you tried… writing better tests?", + "I saw that. we all saw that.", + "F", +] + +const ERROR_REACTIONS = [ + "that's not great.", + "I wouldn't panic. but I'd hurry.", + "error? never heard of her.", + "that stack trace is… something.", + "classic.", +] + +function pickReaction(pool: string[], seed: number): string { + return pool[Math.abs(seed) % pool.length]! +} + +// ─── Message text extraction ───────────────────────────────── + +function findLatestAssistantText(messages: unknown[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] as Record | undefined + if (!msg) continue + if (msg.type === 'assistant') { + // assistant message has .message.content[] + const content = (msg.message as Record | undefined) + ?.content + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === 'object' && + 'type' in block && + block.type === 'text' && + 'text' in block + ) { + return block.text as string + } + } + } + return undefined + } + // Also check tool_result blocks for bash output + if (msg.type === 'tool_result') { + const content = (msg as Record).content + if (typeof content === 'string') return content + } + } + return undefined +} + +function findLatestUserText(messages: unknown[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] as Record | undefined + if (!msg) continue + if (msg.type === 'user') { + const param = msg.param as Record | undefined + if (param && typeof param.text === 'string') return param.text + } + } + return undefined +} + +// ─── Core detection ────────────────────────────────────────── + +export function detectReactionReason( + messages: unknown[], + companionName: string, +): ReactionReason | null { + // Check if user addressed companion by name + const userText = findLatestUserText(messages) + if (userText && userText.toLowerCase().includes(companionName.toLowerCase())) { + return 'addressed' + } + + // Check recent output for test failures / errors + const outputText = findLatestAssistantText(messages) + if (outputText) { + if (TEST_FAILURE_RE.test(outputText)) return 'test_failed' + if (ERROR_RE.test(outputText)) return 'error' + } + + return null +} + +export function buildLocalReaction( + reason: ReactionReason, + seed: number, +): string { + switch (reason) { + case 'addressed': + return pickReaction(ADDRESSED_REACTIONS, seed) + case 'test_failed': + return pickReaction(TEST_FAILED_REACTIONS, seed) + case 'error': + return pickReaction(ERROR_REACTIONS, seed) + } +} + +// ─── Public API ────────────────────────────────────────────── + +export async function fireCompanionObserver( + messages: unknown[], + callback: (reaction: string | undefined) => void, +): Promise { + const companion = getCompanion() + if (!companion) { + callback(undefined) + return + } + if (getGlobalConfig().companionMuted) { + callback(undefined) + return + } + + const reason = detectReactionReason(messages, companion.name) + if (!reason) { + callback(undefined) + return + } + + const seed = messages.length + companion.name.charCodeAt(0) + const reaction = buildLocalReaction(reason, seed) + callback(reaction) +} + +// ─── Self-register on globalThis ───────────────────────────── +// REPL.tsx calls fireCompanionObserver as a bare global (declared in global.d.ts). +// This side-effect import makes it available without modifying REPL.tsx. +;(globalThis as Record).fireCompanionObserver = + fireCompanionObserver diff --git a/src/buddy/types.ts b/src/buddy/types.ts index 8f1c82aab1..46c53c7671 100644 --- a/src/buddy/types.ts +++ b/src/buddy/types.ts @@ -111,6 +111,7 @@ export type CompanionBones = { export type CompanionSoul = { name: string personality: string + seed?: string } export type Companion = CompanionBones & diff --git a/src/commands/buddy/__tests__/buddy.test.ts b/src/commands/buddy/__tests__/buddy.test.ts new file mode 100644 index 0000000000..98c2d37c20 --- /dev/null +++ b/src/commands/buddy/__tests__/buddy.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test, beforeEach } from 'bun:test' +import { getGlobalConfig, saveGlobalConfig } from '../../../utils/config.js' +import { getCompanion } from '../../../buddy/companion.js' + +// Upstream buddy.ts uses LocalCommandCall: call(args, context) → LocalCommandResult +const { call } = await import('../buddy.js') + +// Reset config before each test (NODE_ENV=test uses in-memory object) +function resetConfig() { + saveGlobalConfig(() => ({ + ...getGlobalConfig(), + companion: undefined as any, + companionMuted: undefined as any, + })) +} + +describe('/buddy command', () => { + beforeEach(() => { + resetConfig() + }) + + test('/buddy with no companion shows hint to hatch', async () => { + expect(getCompanion()).toBeUndefined() + const result = await call('', {} as any) + expect(result.type).toBe('text') + expect(result.value).toContain('hatch') + }) + + test('/buddy hatch creates a new companion', async () => { + expect(getCompanion()).toBeUndefined() + const result = await call('hatch', {} as any) + + expect(result.type).toBe('text') + expect(result.value).toContain('companion appeared') + + const config = getGlobalConfig() + expect(config.companion).toBeDefined() + expect(config.companion!.name).toBeTruthy() + expect(config.companion!.personality).toBeTruthy() + expect(config.companion!.hatchedAt).toBeGreaterThan(0) + }) + + test('/buddy shows existing companion card', async () => { + // Hatch first + await call('hatch', {} as any) + const name = getGlobalConfig().companion!.name + + // Show card + const result = await call('', {} as any) + expect(result.type).toBe('text') + expect(result.value).toContain(name) + expect(result.value).toContain('Stats') + }) + + test('/buddy hatch again hints about existing companion', async () => { + await call('hatch', {} as any) + const result = await call('hatch', {} as any) + expect(result.type).toBe('text') + expect(result.value).toContain('already have a companion') + }) + + test('/buddy rehatch replaces existing companion', async () => { + await call('hatch', {} as any) + const first = getGlobalConfig().companion! + + const result = await call('rehatch', {} as any) + expect(result.type).toBe('text') + expect(result.value).toContain('new companion appeared') + + const second = getGlobalConfig().companion! + expect(second.hatchedAt).toBeGreaterThanOrEqual(first.hatchedAt) + }) + + test('/buddy off sets companionMuted to true', async () => { + const result = await call('off', {} as any) + expect(getGlobalConfig().companionMuted).toBe(true) + expect(result.value).toContain('muted') + }) + + test('/buddy on sets companionMuted to false', async () => { + saveGlobalConfig(c => ({ ...c, companionMuted: true })) + const result = await call('on', {} as any) + expect(getGlobalConfig().companionMuted).toBe(false) + expect(result.value).toContain('unmuted') + }) + + test('/buddy mute and /buddy unmute also work', async () => { + const r1 = await call('mute', {} as any) + expect(getGlobalConfig().companionMuted).toBe(true) + expect(r1.value).toContain('muted') + + const r2 = await call('unmute', {} as any) + expect(getGlobalConfig().companionMuted).toBe(false) + expect(r2.value).toContain('unmuted') + }) + + test('/buddy pet with no companion shows hint', async () => { + const result = await call('pet', {} as any) + expect(result.value).toContain('hatch') + }) + + test('/buddy pet with companion shows hearts', async () => { + await call('hatch', {} as any) + const name = getGlobalConfig().companion!.name + const result = await call('pet', {} as any) + expect(result.value).toContain(name) + expect(result.value).toContain('♥') + }) + + test('invalid subcommand shows usage', async () => { + const result = await call('dance', {} as any) + expect(result.value).toContain('Unknown command') + expect(result.value).toContain('/buddy') + }) +}) diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts new file mode 100644 index 0000000000..173e9275ca --- /dev/null +++ b/src/commands/buddy/buddy.ts @@ -0,0 +1,236 @@ +import { + getCompanion, + rollWithSeed, + generateSeed, +} from '../../buddy/companion.js' +import { + type StoredCompanion, + RARITY_STARS, + STAT_NAMES, +} from '../../buddy/types.js' +import { renderSprite } from '../../buddy/sprites.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import type { LocalCommandCall } from '../../types/command.js' + +// Species → default name fragments for hatch (no API needed) +const SPECIES_NAMES: Record = { + duck: 'Waddles', + goose: 'Goosberry', + blob: 'Gooey', + cat: 'Whiskers', + dragon: 'Ember', + octopus: 'Inky', + owl: 'Hoots', + penguin: 'Waddleford', + turtle: 'Shelly', + snail: 'Trailblazer', + ghost: 'Casper', + axolotl: 'Axie', + capybara: 'Chill', + cactus: 'Spike', + robot: 'Byte', + rabbit: 'Flops', + mushroom: 'Spore', + chonk: 'Chonk', +} + +const SPECIES_PERSONALITY: Record = { + duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.', + goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.', + blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.', + cat: 'Independent and judgmental. Watches you type with mild disdain.', + dragon: 'Fiery and passionate about architecture. Hoards good variable names.', + octopus: 'Multitasker extraordinaire. Wraps tentacles around every problem at once.', + owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.', + penguin: 'Cool under pressure. Slides gracefully through merge conflicts.', + turtle: 'Patient and thorough. Believes slow and steady wins the deploy.', + snail: 'Methodical and leaves a trail of useful comments. Never rushes.', + ghost: 'Ethereal and appears at the worst possible moments with spooky insights.', + axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.', + capybara: 'Zen master. Remains calm while everything around is on fire.', + cactus: 'Prickly on the outside but full of good intentions. Thrives on neglect.', + robot: 'Efficient and literal. Processes feedback in binary.', + rabbit: 'Energetic and hops between tasks. Finishes before you start.', + mushroom: 'Quietly insightful. Grows on you over time.', + chonk: 'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.', +} + +function speciesLabel(species: string): string { + return species.charAt(0).toUpperCase() + species.slice(1) +} + +function renderStats(stats: Record): string { + const lines = STAT_NAMES.map(name => { + const val = stats[name] ?? 0 + const filled = Math.round(val / 5) + const bar = '█'.repeat(filled) + '░'.repeat(20 - filled) + return ` ${name.padEnd(10)} ${bar} ${val}` + }) + return lines.join('\n') +} + +export const call: LocalCommandCall = async (args, _context) => { + const sub = args.trim().toLowerCase() + const config = getGlobalConfig() + + // /buddy — show current companion or hint to hatch + if (sub === '') { + const companion = getCompanion() + if (!companion) { + return { + type: 'text', + value: + "You don't have a companion yet! Use /buddy hatch to get one.", + } + } + const stars = RARITY_STARS[companion.rarity] + const sprite = renderSprite(companion, 0) + const shiny = companion.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + sprite.join('\n'), + '', + ` ${companion.name} the ${speciesLabel(companion.species)}${shiny}`, + ` Rarity: ${stars} (${companion.rarity})`, + ` Eye: ${companion.eye} Hat: ${companion.hat}`, + companion.personality ? `\n "${companion.personality}"` : '', + '', + ' Stats:', + renderStats(companion.stats), + '', + ' Commands: /buddy pet /buddy off /buddy on /buddy hatch /buddy rehatch', + ] + return { type: 'text', value: lines.join('\n') } + } + + // /buddy hatch — create a new companion + if (sub === 'hatch') { + if (config.companion) { + return { + type: 'text', + value: `You already have a companion! Use /buddy to see it.\n(Tip: /buddy hatch again will re-roll a new one.)`, + } + } + + const seed = generateSeed() + const r = rollWithSeed(seed) + const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' + const personality = + SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.' + + const stored: StoredCompanion = { + name, + personality, + seed, + hatchedAt: Date.now(), + } + + saveGlobalConfig(cfg => ({ ...cfg, companion: stored })) + + const stars = RARITY_STARS[r.bones.rarity] + const sprite = renderSprite(r.bones, 0) + const shiny = r.bones.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + ' 🎉 A wild companion appeared!', + '', + sprite.join('\n'), + '', + ` ${name} the ${speciesLabel(r.bones.species)}${shiny}`, + ` Rarity: ${stars} (${r.bones.rarity})`, + ` "${personality}"`, + '', + ' Your companion will now appear beside your input box!', + ] + return { type: 'text', value: lines.join('\n') } + } + + // /buddy pet — trigger heart animation + if (sub === 'pet') { + const companion = getCompanion() + if (!companion) { + return { + type: 'text', + value: + "You don't have a companion yet! Use /buddy hatch to get one.", + } + } + + try { + const { setAppState } = await import('../../state/AppStateStore.js') + setAppState(prev => ({ + ...prev, + companionPetAt: Date.now(), + })) + } catch { + // non-interactive mode — AppState not available + } + + return { + type: 'text', + value: ` ${renderSprite(companion, 0).join('\n')}\n\n ${companion.name} purrs happily! ♥`, + } + } + + // /buddy mute | /buddy off + if (sub === 'mute' || sub === 'off') { + if (config.companionMuted) { + return { type: 'text', value: ' Companion is already muted.' } + } + saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true })) + return { type: 'text', value: ' Companion muted. It will hide quietly. Use /buddy unmute to bring it back.' } + } + + // /buddy unmute | /buddy on + if (sub === 'unmute' || sub === 'on') { + if (!config.companionMuted) { + return { type: 'text', value: ' Companion is not muted.' } + } + saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) + return { type: 'text', value: ' Companion unmuted! Welcome back.' } + } + + // /buddy rehatch — re-roll a new companion (replaces existing) + if (sub === 'rehatch') { + const seed = generateSeed() + const r = rollWithSeed(seed) + const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' + const personality = + SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.' + + const stored: StoredCompanion = { + name, + personality, + seed, + hatchedAt: Date.now(), + } + + saveGlobalConfig(cfg => ({ ...cfg, companion: stored })) + + const stars = RARITY_STARS[r.bones.rarity] + const sprite = renderSprite(r.bones, 0) + const shiny = r.bones.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + ' 🎉 A new companion appeared!', + '', + sprite.join('\n'), + '', + ` ${name} the ${speciesLabel(r.bones.species)}${shiny}`, + ` Rarity: ${stars} (${r.bones.rarity})`, + ` "${personality}"`, + '', + ' Your old companion has been replaced!', + ] + return { type: 'text', value: lines.join('\n') } + } + + // Unknown subcommand + return { + type: 'text', + value: + ' Unknown command: /buddy ' + + sub + + '\n Commands: /buddy (info) /buddy hatch /buddy rehatch /buddy pet /buddy off /buddy on', + } +} diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts index 29ae6094c6..476409f5c5 100644 --- a/src/commands/buddy/index.ts +++ b/src/commands/buddy/index.ts @@ -1,3 +1,15 @@ -// Auto-generated stub — replace with real implementation -const _default: Record = {}; -export default _default; +import type { Command } from '../../commands.js' + +// Side-effect: registers fireCompanionObserver on globalThis so REPL.tsx +// can call it as a bare global without import changes. +import '../../buddy/observer.js' + +const buddy = { + type: 'local', + name: 'buddy', + description: 'View and manage your companion buddy', + supportsNonInteractive: false, + load: () => import('./buddy.js'), +} satisfies Command + +export default buddy diff --git a/src/components/BuiltinStatusLine.tsx b/src/components/BuiltinStatusLine.tsx new file mode 100644 index 0000000000..09c2b1cee4 --- /dev/null +++ b/src/components/BuiltinStatusLine.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from 'react'; +import { formatCost } from 'src/cost-tracker.js'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { Box, Text } from 'src/ink.js'; +import { formatTokens } from 'src/utils/format.js'; +import { ProgressBar } from 'src/components/design-system/ProgressBar.js'; + +type RateLimitBucket = { + utilization: number; + resets_at: number; +}; + +type BuiltinStatusLineProps = { + modelName: string; + contextUsedPct: number; + usedTokens: number; + contextWindowSize: number; + totalCostUsd: number; + rateLimits: { + five_hour?: RateLimitBucket; + seven_day?: RateLimitBucket; + }; +}; + +/** + * Format a countdown from now until the given epoch time (in seconds). + * Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now". + */ +export function formatCountdown(epochSeconds: number): string { + const diff = epochSeconds - Date.now() / 1000; + if (diff <= 0) return 'now'; + + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + const minutes = Math.floor((diff % 3600) / 60); + + if (days >= 1) return `${days}d${hours}h`; + if (hours >= 1) return `${hours}h${minutes}m`; + return `${minutes}m`; +} + +function Separator() { + return {' \u2502 '}; +} + +function BuiltinStatusLineInner({ + modelName, + contextUsedPct, + usedTokens, + contextWindowSize, + totalCostUsd, + rateLimits, +}: BuiltinStatusLineProps) { + const { columns } = useTerminalSize(); + + // Force re-render every 60s so countdowns stay current + const [tick, setTick] = useState(0); + useEffect(() => { + const hasResetTime = rateLimits.five_hour?.resets_at || rateLimits.seven_day?.resets_at; + if (!hasResetTime) return; + const id = setInterval(() => setTick(t => t + 1), 60_000); + return () => clearInterval(id); + }, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]); + + // Suppress unused-variable lint for tick (it exists only to trigger re-renders) + void tick; + + // Model display: use first two words (e.g. "Opus 4.6") instead of just first word + const modelParts = modelName.split(' '); + const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName; + + const wide = columns >= 100; + const narrow = columns < 60; + + const hasFiveHour = rateLimits.five_hour != null; + const hasSevenDay = rateLimits.seven_day != null; + + const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0; + const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0; + + // Token display: "50k/1M" + const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`; + + return ( + + {/* Model name */} + {shortModel} + + {/* Context usage with token counts */} + + Context + {contextUsedPct}% + {!narrow && ({tokenDisplay})} + + {/* 5-hour session rate limit */} + {hasFiveHour && ( + <> + + Session + {wide && ( + <> + + + + )} + {fiveHourPct}% + {!narrow && rateLimits.five_hour!.resets_at > 0 && ( + {formatCountdown(rateLimits.five_hour!.resets_at)} + )} + + )} + + {/* 7-day weekly rate limit */} + {hasSevenDay && ( + <> + + Weekly + {wide && ( + <> + + + + )} + {sevenDayPct}% + {!narrow && rateLimits.seven_day!.resets_at > 0 && ( + {formatCountdown(rateLimits.seven_day!.resets_at)} + )} + + )} + + {/* Cost */} + {totalCostUsd > 0 && ( + <> + + {formatCost(totalCostUsd)} + + )} + + ); +} + +export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner); diff --git a/src/components/StatusLine.tsx b/src/components/StatusLine.tsx index 509ccda893..480fae1ab9 100644 --- a/src/components/StatusLine.tsx +++ b/src/components/StatusLine.tsx @@ -1,323 +1,65 @@ -import { feature } from 'bun:bundle'; import * as React from 'react'; -import { memo, useCallback, useEffect, useRef } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'; -import { getIsRemoteMode, getKairosActive, getMainThreadAgentType, getOriginalCwd, getSdkBetas, getSessionId } from '../bootstrap/state.js'; -import { DEFAULT_OUTPUT_STYLE_NAME } from '../constants/outputStyles.js'; -import { useNotifications } from '../context/notifications.js'; -import { getTotalAPIDuration, getTotalCost, getTotalDuration, getTotalInputTokens, getTotalLinesAdded, getTotalLinesRemoved, getTotalOutputTokens } from '../cost-tracker.js'; -import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; -import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js'; -import { Ansi, Box, Text } from '../ink.js'; -import { getRawUtilization } from '../services/claudeAiLimits.js'; -import type { Message } from '../types/message.js'; -import type { StatusLineCommandInput } from '../types/statusLine.js'; -import type { VimMode } from '../types/textInputTypes.js'; -import { checkHasTrustDialogAccepted } from '../utils/config.js'; -import { calculateContextPercentages, getContextWindowForModel } from '../utils/context.js'; -import { getCwd } from '../utils/cwd.js'; -import { logForDebugging } from '../utils/debug.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { createBaseHookInput, executeStatusLineCommand } from '../utils/hooks.js'; -import { getLastAssistantMessage } from '../utils/messages.js'; -import { getRuntimeMainLoopModel, type ModelName, renderModelName } from '../utils/model/model.js'; -import { getCurrentSessionTitle } from '../utils/sessionStorage.js'; -import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js'; -import { getCurrentWorktreeSession } from '../utils/worktree.js'; -import { isVimModeEnabled } from './PromptInput/utils.js'; -export function statusLineShouldDisplay(settings: ReadonlySettings): boolean { - // Assistant mode: statusline fields (model, permission mode, cwd) reflect the - // REPL/daemon process, not what the agent child is actually running. Hide it. - if (feature('KAIROS') && getKairosActive()) return false; - return settings?.statusLine !== undefined; -} -function buildStatusLineCommandInput(permissionMode: PermissionMode, exceeds200kTokens: boolean, settings: ReadonlySettings, messages: Message[], addedDirs: string[], mainLoopModel: ModelName, vimMode?: VimMode): StatusLineCommandInput { - const agentType = getMainThreadAgentType(); - const worktreeSession = getCurrentWorktreeSession(); - const runtimeModel = getRuntimeMainLoopModel({ - permissionMode, - mainLoopModel, - exceeds200kTokens - }); - const outputStyleName = settings?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME; - const currentUsage = getCurrentUsage(messages); - const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); - const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize); - const sessionId = getSessionId(); - const sessionName = getCurrentSessionTitle(sessionId); - const rawUtil = getRawUtilization(); - const rateLimits: StatusLineCommandInput['rate_limits'] = { - ...(rawUtil.five_hour && { - five_hour: { - used_percentage: rawUtil.five_hour.utilization * 100, - resets_at: rawUtil.five_hour.resets_at - } - }), - ...(rawUtil.seven_day && { - seven_day: { - used_percentage: rawUtil.seven_day.utilization * 100, - resets_at: rawUtil.seven_day.resets_at - } - }) - }; - return { - ...createBaseHookInput(), - ...(sessionName && { - session_name: sessionName - }), - model: { - id: runtimeModel, - display_name: renderModelName(runtimeModel) - }, - workspace: { - current_dir: getCwd(), - project_dir: getOriginalCwd(), - added_dirs: addedDirs - }, - version: MACRO.VERSION, - output_style: { - name: outputStyleName - }, - cost: { - total_cost_usd: getTotalCost(), - total_duration_ms: getTotalDuration(), - total_api_duration_ms: getTotalAPIDuration(), - total_lines_added: getTotalLinesAdded(), - total_lines_removed: getTotalLinesRemoved() - }, - context_window: { - total_input_tokens: getTotalInputTokens(), - total_output_tokens: getTotalOutputTokens(), - context_window_size: contextWindowSize, - current_usage: currentUsage, - used_percentage: contextPercentages.used, - remaining_percentage: contextPercentages.remaining - }, - exceeds_200k_tokens: exceeds200kTokens, - ...((rateLimits.five_hour || rateLimits.seven_day) && { - rate_limits: rateLimits - }), - ...(isVimModeEnabled() && { - vim: { - mode: vimMode ?? 'INSERT' - } - }), - ...(agentType && { - agent: { - name: agentType - } - }), - ...(getIsRemoteMode() && { - remote: { - session_id: getSessionId() - } - }), - ...(worktreeSession && { - worktree: { - name: worktreeSession.worktreeName, - path: worktreeSession.worktreePath, - branch: worktreeSession.worktreeBranch, - original_cwd: worktreeSession.originalCwd, - original_branch: worktreeSession.originalBranch - } - }) - }; +import { memo } from 'react'; +import { getSdkBetas } from 'src/bootstrap/state.js'; +import { getTotalCost } from 'src/cost-tracker.js'; +import { useMainLoopModel } from 'src/hooks/useMainLoopModel.js'; +import { type ReadonlySettings } from 'src/hooks/useSettings.js'; +import { getRawUtilization } from 'src/services/claudeAiLimits.js'; +import { useAppState } from 'src/state/AppState.js'; +import type { Message } from 'src/types/message.js'; +import { calculateContextPercentages, getContextWindowForModel } from 'src/utils/context.js'; +import { getLastAssistantMessage } from 'src/utils/messages.js'; +import { getRuntimeMainLoopModel, renderModelName } from 'src/utils/model/model.js'; +import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from 'src/utils/tokens.js'; +import { BuiltinStatusLine } from 'src/components/BuiltinStatusLine.js'; + +export function statusLineShouldDisplay(_settings: ReadonlySettings): boolean { + return true; } + type Props = { - // messages stays behind a ref (read only in the debounced callback); - // lastAssistantMessageId is the actual re-render trigger. messagesRef: React.RefObject; lastAssistantMessageId: string | null; - vimMode?: VimMode; + vimMode?: unknown; }; + export function getLastAssistantMessageId(messages: Message[]): string | null { return getLastAssistantMessage(messages)?.uuid ?? null; } -function StatusLineInner({ - messagesRef, - lastAssistantMessageId, - vimMode -}: Props): React.ReactNode { - const abortControllerRef = useRef(undefined); - const permissionMode = useAppState(s => s.toolPermissionContext.mode); - const additionalWorkingDirectories = useAppState(s => s.toolPermissionContext.additionalWorkingDirectories); - const statusLineText = useAppState(s => s.statusLineText); - const setAppState = useSetAppState(); - const settings = useSettings(); - const { - addNotification - } = useNotifications(); - // AppState-sourced model — same source as API requests. getMainLoopModel() - // re-reads settings.json on every call, so another session's /model write - // would leak into this session's statusline (anthropics/claude-code#37596). - const mainLoopModel = useMainLoopModel(); - - // Keep latest values in refs for stable callback access - const settingsRef = useRef(settings); - settingsRef.current = settings; - const vimModeRef = useRef(vimMode); - vimModeRef.current = vimMode; - const permissionModeRef = useRef(permissionMode); - permissionModeRef.current = permissionMode; - const addedDirsRef = useRef(additionalWorkingDirectories); - addedDirsRef.current = additionalWorkingDirectories; - const mainLoopModelRef = useRef(mainLoopModel); - mainLoopModelRef.current = mainLoopModel; - - // Track previous state to detect changes and cache expensive calculations - const previousStateRef = useRef<{ - messageId: string | null; - exceeds200kTokens: boolean; - permissionMode: PermissionMode; - vimMode: VimMode | undefined; - mainLoopModel: ModelName; - }>({ - messageId: null, - exceeds200kTokens: false, - permissionMode, - vimMode, - mainLoopModel - }); - - // Debounce timer ref - const debounceTimerRef = useRef | undefined>(undefined); - // True when the next invocation should log its result (first run or after settings reload) - const logNextResultRef = useRef(true); - - // Stable update function — reads latest values from refs - const doUpdate = useCallback(async () => { - // Cancel any in-flight requests - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - const msgs = messagesRef.current; - const logResult = logNextResultRef.current; - logNextResultRef.current = false; - try { - let exceeds200kTokens = previousStateRef.current.exceeds200kTokens; - - // Only recalculate 200k check if messages changed - const currentMessageId = getLastAssistantMessageId(msgs); - if (currentMessageId !== previousStateRef.current.messageId) { - exceeds200kTokens = doesMostRecentAssistantMessageExceed200k(msgs); - previousStateRef.current.messageId = currentMessageId; - previousStateRef.current.exceeds200kTokens = exceeds200kTokens; - } - const statusInput = buildStatusLineCommandInput(permissionModeRef.current, exceeds200kTokens, settingsRef.current, msgs, Array.from(addedDirsRef.current.keys()), mainLoopModelRef.current, vimModeRef.current); - const text = await executeStatusLineCommand(statusInput, controller.signal, undefined, logResult); - if (!controller.signal.aborted) { - setAppState(prev => { - if (prev.statusLineText === text) return prev; - return { - ...prev, - statusLineText: text - }; - }); - } - } catch { - // Silently ignore errors in status line updates - } - }, [messagesRef, setAppState]); - - // Stable debounced schedule function — no deps, uses refs - const scheduleUpdate = useCallback(() => { - if (debounceTimerRef.current !== undefined) { - clearTimeout(debounceTimerRef.current); - } - debounceTimerRef.current = setTimeout((ref, doUpdate) => { - ref.current = undefined; - void doUpdate(); - }, 300, debounceTimerRef, doUpdate); - }, [doUpdate]); - - // Only trigger update when assistant message, permission mode, vim mode, or model actually changes - useEffect(() => { - if (lastAssistantMessageId !== previousStateRef.current.messageId || permissionMode !== previousStateRef.current.permissionMode || vimMode !== previousStateRef.current.vimMode || mainLoopModel !== previousStateRef.current.mainLoopModel) { - // Don't update messageId here — let doUpdate handle it so - // exceeds200kTokens is recalculated with the latest messages - previousStateRef.current.permissionMode = permissionMode; - previousStateRef.current.vimMode = vimMode; - previousStateRef.current.mainLoopModel = mainLoopModel; - scheduleUpdate(); - } - }, [lastAssistantMessageId, permissionMode, vimMode, mainLoopModel, scheduleUpdate]); - - // When the statusLine command changes (hot reload), log the next result - const statusLineCommand = settings?.statusLine?.command; - const isFirstSettingsRender = useRef(true); - useEffect(() => { - if (isFirstSettingsRender.current) { - isFirstSettingsRender.current = false; - return; - } - logNextResultRef.current = true; - void doUpdate(); - }, [statusLineCommand, doUpdate]); - - // Separate effect for logging on mount - useEffect(() => { - const statusLine = settings?.statusLine; - if (statusLine) { - logEvent('tengu_status_line_mount', { - command_length: statusLine.command.length, - padding: statusLine.padding - }); - // Log if status line is configured but disabled by disableAllHooks - if (settings.disableAllHooks === true) { - logForDebugging('Status line is configured but disableAllHooks is true', { - level: 'warn' - }); - } - // executeStatusLineCommand (hooks.ts) returns undefined when trust is - // blocked — statusLineText stays undefined forever, user sees nothing, - // and tengu_status_line_mount above fires anyway so telemetry looks fine. - if (!checkHasTrustDialogAccepted()) { - addNotification({ - key: 'statusline-trust-blocked', - text: 'statusline skipped · restart to fix', - color: 'warning', - priority: 'low' - }); - logForDebugging('Status line command skipped: workspace trust not accepted', { - level: 'warn' - }); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, []); // Only run once on mount - settings stable for initial logging +function StatusLineInner({ messagesRef, lastAssistantMessageId }: Props): React.ReactNode { + const mainLoopModel = useMainLoopModel(); + const permissionMode = useAppState(s => s.toolPermissionContext.mode); - // Initial update on mount + cleanup on unmount - useEffect(() => { - void doUpdate(); - return () => { - abortControllerRef.current?.abort(); - if (debounceTimerRef.current !== undefined) { - clearTimeout(debounceTimerRef.current); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, []); // Only run once on mount, not when doUpdate changes + const exceeds200kTokens = lastAssistantMessageId + ? doesMostRecentAssistantMessageExceed200k(messagesRef.current) + : false; - // Get padding from settings or default to 0 - const paddingX = settings?.statusLine?.padding ?? 0; + const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens }); + const modelDisplay = renderModelName(runtimeModel); + const currentUsage = getCurrentUsage(messagesRef.current); + const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); + const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize); + const rawUtil = getRawUtilization(); + const totalCost = getTotalCost(); + // Derive usedTokens from currentUsage (same source as contextUsedPct) for consistency + const usedTokens = currentUsage + ? currentUsage.input_tokens + + currentUsage.output_tokens + + currentUsage.cache_creation_input_tokens + + currentUsage.cache_read_input_tokens + : 0; - // StatusLine must have stable height in fullscreen — the footer is - // flexShrink:0 so a 0→1 row change when the command finishes steals - // a row from ScrollBox and shifts content. Reserve the row while loading - // (same trick as PromptInputFooterLeftSide). - return - {statusLineText ? - {statusLineText} - : isFullscreenEnvEnabled() ? : null} - ; + return ( + + ); } -// Parent (PromptInputFooter) re-renders on every setMessages, but StatusLine's -// own props now only change when lastAssistantMessageId flips — memo keeps it -// from being dragged along (previously ~18 no-prop-change renders per session). export const StatusLine = memo(StatusLineInner); diff --git a/src/components/__tests__/BuiltinStatusLine.test.ts b/src/components/__tests__/BuiltinStatusLine.test.ts new file mode 100644 index 0000000000..3be9a187cf --- /dev/null +++ b/src/components/__tests__/BuiltinStatusLine.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, test, expect, spyOn } from 'bun:test' +import { formatCountdown } from 'src/components/BuiltinStatusLine.js' + +describe('formatCountdown', () => { + const FIXED_NOW = 1700000000000 // stable timestamp in ms + const nowSec = FIXED_NOW / 1000 + let spy: ReturnType + + beforeEach(() => { + spy = spyOn(Date, 'now').mockReturnValue(FIXED_NOW) + }) + + afterEach(() => { + spy.mockRestore() + }) + + test('returns "now" for past time', () => { + expect(formatCountdown(nowSec - 60)).toBe('now') + }) + + test('returns "now" for exactly zero diff', () => { + expect(formatCountdown(nowSec)).toBe('now') + }) + + test('returns minutes for less than 1 hour', () => { + expect(formatCountdown(nowSec + 45 * 60)).toBe('45m') + }) + + test('returns hours and minutes for less than 1 day', () => { + expect(formatCountdown(nowSec + 3 * 3600 + 12 * 60)).toBe('3h12m') + }) + + test('returns hours with 0 minutes', () => { + expect(formatCountdown(nowSec + 1 * 3600)).toBe('1h0m') + }) + + test('returns days and hours for 1+ days', () => { + expect(formatCountdown(nowSec + 5 * 86400 + 20 * 3600)).toBe('5d20h') + }) +})