diff --git a/.gitignore b/.gitignore index e0f540ba..b9c4a509 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ logs/ *.bun-build tsconfig.tsbuildinfo .dev-home/ +.dev-home\ / # Local agent harness state .claude/ @@ -41,3 +42,12 @@ tsconfig.tsbuildinfo experiment/*.log experiment/last-outcome.json /experiment + +# Dev/test home directories (contain runtime db, logs, cache) +packages/test_dev_home* +test_migration.db + +# Other dev/temp artifacts +.atomcode/ +temp_ghostty/ +fix_hosts.ps1 diff --git a/.mimocode/mimocode.jsonc b/.mimocode/mimocode.jsonc index 7276ef25..f000dd1e 100644 --- a/.mimocode/mimocode.jsonc +++ b/.mimocode/mimocode.jsonc @@ -6,5 +6,20 @@ "packages/opencode/migration/*": "deny", }, }, - "mcp": {}, + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "@playwright/mcp", "--browser", "chrome"], + "environment": { + "PLAYWRIGHT_BROWSERS_PATH": "C:\\Users\\Administrator.LAPTOP-QMJNJC9F\\AppData\\Local\\ms-playwright" + }, + "timeout": 120000, + "enabled": true + }, + "chrome-devtools": { + "type": "local", + "command": ["npx", "-y", "chrome-devtools-mcp@latest"], + "enabled": true + } + }, } diff --git a/AGENTS.md b/AGENTS.md index 48322c64..1ce17d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,154 @@ -- Always use superpowers skill instead of builtin plan mode. -- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. -- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. -- The default branch in this repo is `dev`. -- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. -- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. +--- +> **/init 规范管控生效中** — 以下规则由 `/init` 命令加载,所有交互强制遵守。 +> 语言配置:`zh.ts` — 全程中文模式 +--- + +## 0. 语言强制约束(/init 激活,永久生效) + +1. **所有自然语言输出必须使用简体中文**。包括但不限于:需求分析、方案描述、代码解释、报错分析、总结汇报、对话问答。 +2. **允许保留英文的例外**:编程语言关键字、函数名/类名、开源项目原名、通用专业缩写(API/SDK/CLI/CI等)、代码内标识符、技术术语(如 lint/typecheck/debug)。 +3. **禁止**:整段英文回复、英文总结、默认英文解释需求。 +4. **代码注释优先中文**,长说明必须中文,单行注释可用中文简要说明逻辑。 +5. **报错提示、指引文案**统一中文展示。 +6. 若检测到语言异常,自动切换纯中文模式并提示: + > 语言配置校验触发,已启用强制中文回复模式,遵循 AGENTS.md 约束执行交互。 + +## 0.1 开发编码规范 + +1. **需求分析**:中文梳理需求边界、业务逻辑。 +2. **设计文档**:接口说明、数据表设计、流程图文案全部中文。 +3. **交付产物**:README、使用说明、部署文档统一中文撰写。 +4. **对话回复结构**: + - 先结论总述 + - 分点展开细节 + - 必要补充注意事项/落地步骤 + - 使用 Markdown 排版,代码块注释使用中文说明逻辑 + +## 0.2 命令说明 + +- **`/init`**:重载 AGENTS.md 全部规则,绑定 zh.ts 中文语言配置,重置会话上下文约束。 +- **`/review`**:审查未提交更改、commit、branch 或 PR,从近期工作中提取重复工作流封装为可复用 skills。 +- **`/distill`**:设定明确停止条件目标,持续运行直到评估机制判定目标达成,达成后自动清理目标(goal clear)。 + +## Repo structure + +- `packages/opencode`: Core CLI, server, and all business logic. Entry: `src/index.ts` (yargs CLI, script name `mimo`).子包内有独立 `AGENTS.md` 提供更详细的 Effect/模块约定。 +- `packages/app`: Web UI, SolidJS + Vite。子包内有独立 `AGENTS.md`。 +- `packages/desktop`: Native desktop app, Electron (wraps `packages/app`)。子包内有独立 `AGENTS.md`。 +- `packages/ui`: Shared UI components, SolidJS。 +- `packages/shared`: `@mimo-ai/shared` — utilities shared across packages。 +- `packages/sdk/js`: `@mimo-ai/sdk` — generated JS client from OpenAPI。 +- `packages/console`: Console sub-app (multi-service: `console/app`, `console/core`, `console/function`, `console/mail`, `console/resource`)。 +- `packages/plugin`: `@mimo-ai/plugin` — plugin API definitions (tool/tui)。 +- `packages/script`: `@mimo-ai/script` — build/release scripts。 +- `packages/function`: `@mimo-ai/function` — Cloudflare Workers functions (GitHub integration)。 +- `packages/storybook`: UI 组件 Storybook。 +- `packages/slack`: Slack 集成。 +- `packages/identity`: Logo 资源(非代码包)。 +- `packages/enterprise`: 企业版功能。 +- `packages/extensions`: 扩展功能。 +- `infra/`: SST infrastructure (Cloudflare + Stripe + PlanetScale)。 +- `script/`: Repo-level scripts (generate, release, changelog, etc.)。 + +## Commands + +### From repo root + +```bash +bun install # Install deps (postinstall runs fix-node-pty) +bun dev # Run opencode CLI in dev mode (from packages/opencode) +bun dev # Run against a specific directory +bun lint # oxlint (repo-wide) +bun typecheck # turbo typecheck (all packages) +``` + +### From packages/opencode + +```bash +bun run build # Build standalone executable +bun run build:dev # Build single-platform prod binary +bun test # Run tests (bun test --timeout 30000) +bun typecheck # tsgo --noEmit (NOT tsc) +bun run db generate --name # Generate Drizzle migration +``` + +### From packages/app + +```bash +bun run dev # Vite dev server +bun run test:unit # bun test --preload ./happydom.ts ./src +bun run test:e2e # Playwright tests +``` + +### From packages/desktop + +```bash +bun run dev # Electron dev (web only) +``` + +**Never run `tsc` directly.** Always use `bun typecheck` from a package directory. +**Never run tests from repo root** (guard: `do-not-run-tests-from-root`). + +## Testing + +- Tests live in `packages/opencode/test/` mirroring `src/` structure. +- **Test fixtures**: Use `tmpdir()` from `test/fixture/fixture.ts` for temp dirs with auto-cleanup. +- **Effect tests**: Use `testEffect(...)` from `test/lib/effect.ts`. Use `it.live(...)` for tests needing real OS behavior (most integration tests). +- `test/preload.ts` sets up isolated env (in-memory SQLite, temp XDG dirs, clean API keys). Import order matters — env vars must be set before any `src/` imports. +- Avoid mocks. Test actual implementation. +- Effect test fixtures: `provideTmpdirInstance(...)` for single temp instance, `tmpdirScoped()` + `provideInstance(...)` for multi-directory tests. + +## SDK generation + +When you change the API or SDK (e.g. `packages/opencode/src/server/server.ts`): + +```bash +./script/generate.ts +``` + +This regenerates `packages/sdk/js` from OpenAPI and reformats. + +## Key architecture notes + +### Effect v4 beta + +This codebase uses **Effect v4 beta** (`effect@4.0.0-beta.48`). Key differences from v3: + +- `Effect.fork` / `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)`. +- Use `Effect.gen(function* () { ... })` for composition. +- Use `Effect.fn("Domain.method")` for named/traced effects. + +### Instance vs global state + +- `Instance` (ALS-based) provides per-project context: `Instance.directory`, `Instance.worktree`, `Instance.project`. +- `InstanceState` (from `src/effect/instance-state.ts`) for per-directory service state that needs cleanup. Uses `ScopedCache` keyed by directory. +- `makeRuntime` (from `src/effect/run-service.ts`) for all services — returns `{ runPromise, runFork, runCallback }`. + +### Module shape + +Do NOT use `export namespace Foo { ... }`. Use flat top-level exports + self-reexport: + +```ts +// src/foo/foo.ts +export const thing = ... +export * as Foo from "./foo" +``` + +Consumers: `import { Foo } from "@/foo/foo"`. + +### Config modules + +In `src/config`, follow the self-export pattern in `index.ts` when adding a new config module. + +### Database + +- Schema: `src/**/*.sql.ts` (snake_case field names, `_id` joins). +- Migrations: `bun run db generate --name ` → `migration/_/`. +- Drizzle config: `packages/opencode/drizzle.config.ts`. + +### CLI entry + +`packages/opencode/src/index.ts` — yargs CLI with script name `mimo`. Commands: `run`, `serve`, `web`, `generate`, `models`, `providers`, `agent`, `upgrade`, `debug`, `stats`, `mcp`, `github`, `export`, `import`, `session`, `db`, `plug`, `acp`, `pr`. ## Style Guide @@ -93,12 +238,35 @@ const table = sqliteTable("session", { }) ``` -## Testing +## Effect code conventions + +- Use `Schema.Class` for multi-field data, `Schema.brand` for single-value types, `Schema.TaggedErrorClass` for typed errors. +- Prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure. +- Use `Effect.callback` for callback-based APIs. +- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)`. +- For background loops: `Effect.repeat` / `Effect.schedule` with `Effect.forkScoped`. +- Use `Effect.cached` for deduplication of concurrent computations. +- `Instance.bind(fn)` for native addon callbacks that need Instance context. Not needed for `setTimeout`, `Promise.then`, or Effect fibers. +- Prefer Effect services over raw platform APIs: `FileSystem`, `HttpClient`, `Path`, `ChildProcessSpawner`, etc. + +## Lint + +- Linter: oxlint with type-aware rules (`oxlint-tsgolint`). +- Config: `.oxlintrc.json` at repo root. +- Key disabled rules: `require-yield` (Effect generators), `no-shadow` (Effect closures), `no-new` (intentional side-effectful constructors). +- Formatter: Prettier (semi: false, printWidth: 120). + +## CI + +- Typecheck workflow: `.github/workflows/typecheck.yml` — runs `bun typecheck` on push/PR to `dev`. +- Pre-push hook: `.husky/pre-push` — validates bun version + runs `bun typecheck`. + +## 子包 AGENTS.md -- Avoid mocks as much as possible -- Test actual implementation, do not duplicate logic into tests -- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. +各子包内有独立 `AGENTS.md`,提供更细粒度的约束。关键约束摘录: -## Type Checking +- **app** (`packages/app/AGENTS.md`):永远不要尝试重启 app 或 server 进程。本地开发时 backend 跑 4096 端口、app 跑 4444 端口,通过 `http://localhost:4444` 验证 UI 改动。 +- **desktop** (`packages/desktop/AGENTS.md`):渲染进程只能调用 `src/preload` 导出的 `window.api`;主进程在 `src/main/ipc.ts` 注册 IPC handler。 +- **opencode** (`packages/opencode/AGENTS.md`):详细的 Effect/InstanceState/Runtime 约束、模块 shape、数据库 schema 规范。 -- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. +修改子包代码时,务必阅读对应子包的 `AGENTS.md`。 diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 00000000..c6175cf2 --- /dev/null +++ b/DEVLOG.md @@ -0,0 +1,798 @@ +# MiMo Code 开发日志 + +> **版本范围**: `0fc4675` → `e96727a` (Initial open-source release → Merge PR #149) +> **日期**: 2026-06-11 +> **总计变更**: 111 文件, +4,328 / -5,036 行 + +--- + +## 一、变更总览 + +| 模块 | 文件数 | 新增行 | 删除行 | 说明 | +|------|--------|--------|--------|------| +| `.mimocode/` 配置与定制 | 34 | +3,109 | 0 | 全新目录,含 agent/command/glossary/plugins/skills/themes | +| `.github/` CI 工作流 | 34 | +1 | -3,084 | 大规模裁剪,仅保留 typecheck | +| `packages/opencode/` 核心 | 20 | +286 | -259 | TUI 兼容、安装/卸载、checkpoint 注释清理 | +| `github/` Action 包 | 整目录删除 | 0 | -1,052 | 完全移除 opencode GitHub Action | +| `script/` 发布脚本 | 3 | +90 | -77 | 精简 publish、新增 release.ts | +| `docs/` 文档 | 1 | +109 | 0 | 新增 build-release.md | +| 根目录 & 资源 | 5 | +768 | -6 | 包名/banner/lock/bun.lock | + +--- + +## 二、详细修改内容 + +### 1. 品牌与身份重塑 + +#### 包名与仓库迁移 + +| 旧值 | 新值 | 涉及文件 | +|------|------|----------| +| `opencode` | `mimocode` | `package.json` | +| `https://github.com/anomalyco/opencode` | `https://github.com/XiaomiMiMo/MiMo-Code` | `package.json`, `README.md`, `README.zh.md` | +| `@mimocode/cli-ai` | `@mimo-ai/cli` | `packages/opencode/src/installation/index.ts`, `packages/opencode/src/cli/cmd/uninstall.ts` | +| 小米内部 npm registry `pkgs.d.xiaomi.net` | npmjs.org 公共 registry | `packages/opencode/src/installation/index.ts` | +| `https://opencode.ai/install` | `https://mimo.xiaomi.com/install` | `packages/opencode/src/installation/index.ts` | + +**为什么要改**: 开源发布不能使用小米内部制品库,需要切到 npmjs.org 公共 registry;包名需统一到 `@mimo-ai` scope 以符合公开品牌。 + +#### README 与 Banner + +- 英文/中文 README 新增 **官网 + 博客** 链接行 + - EN: `Website | Blog` → `https://mimo.xiaomi.com/en/mimocode` + - ZH: `官网 | 博客` → `https://mimo.xiaomi.com/zh/mimocode` +- Banner 图片格式 `.jpg` → `.png`(更高清) +- 社区二维码 `community-qrcode.jpg` 更新 + +**为什么要改**: 开源后用户需要能快速找到官网和社区入口;旧二维码过期需要更新。 + +--- + +### 2. `.mimocode/` 定制系统(全新,+3,109 行) + +| 子目录 | 内容 | 行数 | +|--------|------|------| +| `agent/translator.md` | 多语言翻译 Agent 指令 | 899 | +| `command/*.md` | AI 辅助命令 (ai-deps, changelog, commit, issues, learn, rmslop, spellcheck) | 192 | +| `glossary/*.md` | 17 种语言的术语表 (ar/br/bs/da/de/es/fr/ja/ko/no/pl/ru/th/tr/zh-cn/zh-tw) | 536 | +| `plugins/tui-smoke.tsx` | Smoke 主题 TUI 插件 | 937 | +| `plugins/smoke-theme.json` | Smoke 主题 JSON 定义 | 223 | +| `skills/effect/SKILL.md` | Effect 技能定义 | 21 | +| `themes/mytheme.json` | 自定义主题模板 | 223 | +| `mimocode.jsonc` | MiMoCode 项目配置 | 10 | +| `tui.json` | TUI 布局配置 | 18 | +| `env.d.ts` | 类型声明 | 4 | + +**为什么要改**: 这是 MiMo Code 区别于上游 OpenCode 的核心差异化配置目录,承载了品牌化主题、多语言术语、AI 命令、Agent 人设等。每个项目可以有自己的 `.mimocode/` 实现深度定制。 + +--- + +### 3. TUI 纯终端兼容模式(Plain Terminal Mode) + +#### 问题背景 + +macOS 自带的 **Apple_Terminal** 不支持 Kitty 键盘协议、鼠标事件、高级渲染特性,导致 TUI 体验极差(花屏/卡顿/无交互)。 + +#### 解决方案 + +新增 `isPlainTerminal()` / `isMacNativeTerminal()` 检测函数,自动降级: + +| 特性 | 正常终端 | Plain Terminal | +|------|----------|----------------| +| FPS | 60 | 10 (max 15) | +| Kitty 键盘 | 启用 | 禁用 | +| 鼠标 | 启用 | 禁用 | +| 背景 | 主题色 | 透明 | +| 启动动画 | Spinner | 纯文本 | +| Plugin Slot | 渲染 | 隐藏 | +| 提示 | 无 | "推荐使用 iTerm 或 VS Code 终端" | + +**涉及文件**: + +| 文件 | 变更 | +|------|------| +| `tui/util/terminal.ts` | 新增 `isMacNativeTerminal()`, `isPlainTerminal()` | +| `tui/app.tsx` | `rendererConfig` 接受 `plainTerminal` 参数,降级渲染 | +| `tui/component/startup-loading.tsx` | Plain 模式用文本替代 Spinner | +| `tui/context/theme.tsx` | 新增 `PLAIN_TERMINAL_THEME`,背景全透明 | +| `tui/routes/home.tsx` | Plain 模式隐藏 plugin slot,显示终端提示 | +| `tui/i18n/*.ts` (7 语言) | 新增 `tui.tips.plain_terminal` 翻译 | + +**环境变量控制**: `MIMOCODE_TUI_PLAIN=true/1` 强制启用, `false/0` 强制禁用, 未设置则自动检测 Apple_Terminal。 + +**为什么要改**: macOS 原生终端用户是真实存在的群体,不做降级直接无法使用,属于 P0 兼容性问题。处理耗时是因为涉及渲染层(FPS/键盘/鼠标/主题/组件)全链路改造 + 7 种语言 i18n 同步。 + +--- + +### 4. 安装/升级系统重构 + +#### 核心变更 + +| 项目 | 旧 | 新 | +|------|----|----| +| npm 包名 | `@mimocode/cli-ai` | `@mimo-ai/cli` | +| Registry | 硬编码 `pkgs.d.xiaomi.net` | 动态检测 `npm config get registry` | +| Channel | 硬编码 `latest` | 使用 `InstallationChannel`(支持 latest/beta) | +| curl 安装 | 注释掉 (TODO) | 启用,指向 `mimo.xiaomi.com/install` | +| 安装方式检测 | 注释掉 curl 路径检测 | 启用 `.mimocode/bin` / `.local/bin` 检测 | +| npm 升级命令 | 带 `--registry=pkgs.d.xiaomi.net` | 不指定 registry(使用用户默认) | +| 卸载命令 | `@mimocode/cli-ai` | `@mimo-ai/cli` | + +**为什么要改**: 开源后不能硬编码内部 registry;需要恢复 curl 安装检测逻辑以支持官方安装脚本;动态 registry 检测让企业用户可以用私有 mirror。 + +--- + +### 5. CI/CD 大规模裁剪(-3,084 行) + +#### 删除的工作流 + +| 工作流 | 用途 | 删除原因 | +|--------|------|----------| +| `publish.yml` (479行) | npm + GitHub Release 发布 | 不再使用 GitHub CI 发布 | +| `pr-standards.yml` (351行) | PR 规范检查 | 内部流程,开源不需要 | +| `close-stale-prs.yml` (235行) | 自动关闭过期 PR | 内部管理工具 | +| `duplicate-issues.yml` (177行) | 合并重复 Issue | 内部管理工具 | +| `daily-issues-recap.yml` (170行) | 每日 Issue 摘要 | 内部管理工具 | +| `daily-pr-recap.yml` (173行) | 每日 PR 摘要 | 内部管理工具 | +| `nix-hashes.yml` (152行) | Nix 哈希更新 | 内部构建链 | +| `vouch-check-issue.yml` (116行) | Issue 担保检查 | 内部治理流程 | +| `vouch-check-pr.yml` (114行) | PR 担保检查 | 内部治理流程 | +| `pr-management.yml` / `review.yml` / `compliance-close.yml` 等 | PR 管理/审查/合规 | 内部治理流程 | +| `test.yml` (161行) | CI 测试 | 改为本地执行 | +| `deploy.yml` / `beta.yml` / `generate.yml` 等 | 部署/预览/生成 | 内部发布流程 | + +#### 删除的其他文件 + +| 文件 | 原因 | +|------|------| +| `.github/CODEOWNERS` | 内部团队 CODEOWNER | +| `.github/TEAM_MEMBERS` | 内部团队成员列表 | +| `.github/VOUCHED.td` | 内部担保机制 | +| `.github/actions/setup-git-comitter/` | 内部 Git 提交者配置 | +| `.github/publish-python-sdk.yml` | 内部 Python SDK 发布 | + +#### 保留 + +仅保留 `typecheck.yml` 作为 PR 门控。 + +#### 删除 `github/` 目录(-1,052 行) + +整个 OpenCode GitHub Action 包(`github/index.ts` 1052行)被完全移除,包括: +- GitHub Action 入口 (`action.yml`, `index.ts`) +- SST 部署配置 +- 发布/构建脚本 + +**为什么要改**: 这是开源裁剪的核心工作。内部 CI 依赖小米 GitLab Runner、内部 npm registry、SST 部署、团队治理流程——这些在 GitHub 公开仓库无意义。裁剪后只保留类型检查作为开源协作的基本质量门控。 + +--- + +### 6. 发布脚本重构 + +#### `script/publish.ts` 精简 + +| 删除项 | 行数 | 原因 | +|--------|------|------| +| `prepareReleaseFiles()` 函数 (zed extension.toml 更新) | ~20 | 不再发布 Zed 扩展 | +| git tag/push/switch 流程 | ~25 | 不在 publish 脚本中操作 git | +| `gh release edit` 发布 | ~5 | 移到 release.ts | + +#### 新增 `script/release.ts`(37行) + +一站式发布流程:`version → build → publish npm → finalize release` + +#### `script/version.ts` 容错 + +- `gh release create` / `bun script/changelog.ts` 添加 `.nothrow()` 防止非关键步骤失败阻断流程 + +#### `packages/script/src/index.ts` + +- 移除 `team` 属性(依赖已删除的 `.github/TEAM_MEMBERS`) +- Channel 推断:`git branch --show-current` 空值时默认 `"latest"` 而非空字符串 +- Version bump:无 `OPENCODE_BUMP` 时直接返回原版本,不再提前 split + +**为什么要改**: 原发布脚本混合了版本管理、git 操作、GitHub Release、Zed 扩展发布——开源后只需 npm + GitHub Release 两条线。拆分为 `release.ts`(编排)+ `publish.ts`(上传)更清晰。`.nothrow()` 是实际发布中遇到 changelog 为空或 Release 已存在时容错。 + +--- + +### 7. 新增 Node.js 构建 (`packages/opencode/script/build-node.ts`) + +```ts +// Bun.build → Node ESM +// 关键 define: +// OPENCODE_MIGRATIONS = 内联迁移 SQL +// OPENCODE_CHANNEL = 发布 channel +``` + +- 自动加载 `migration/` 目录下所有迁移 SQL +- 输出到 `dist/node/` +- 排除 `jsonc-parser`, `@lydell/node-pty` 为 external + +**为什么要改**: MiMo Code 需要同时支持 Bun 和 Node.js 运行时,新增 Node 构建目标扩大用户覆盖面。 + +--- + +### 8. Session/Checkpoint 注释清理 + +| 文件 | 变更 | +|------|------| +| `checkpoint-templates.ts` | `cc-haha tail max` → `tail max` | +| `checkpoint.ts` | 移除 6 处 `cc-haha` 引用(算法描述、compact 模式说明、seam framing 注释) | +| `llm.ts` | `mirroring cc-haha PERSISTENT_MAX_BACKOFF_MS` → `with exponential backoff` | + +**为什么要改**: `cc-haha` 是上游内部代号,开源后不应暴露内部项目名。注释改为通用描述,逻辑完全不变。 + +--- + +### 9. 开发体验文件 + +| 新增文件 | 用途 | +|----------|------| +| `.vscode/launch.example.json` | Bun attach 调试配置模板 | +| `.vscode/settings.example.json` | 推荐 Bun 扩展 | +| `docs/build-release.md` | 构建与发布完整文档(109行) | + +--- + +## 三、为什么处理这么久 + +### 时间拆解 + +| 阶段 | 预估耗时 | 原因 | +|------|----------|------| +| 品牌重塑 & 包名迁移 | 中 | 涉及 installation/uninstall/publish/README 等 **10+ 文件**交叉引用,漏改一个就发布失败 | +| CI 裁剪 | 高 | 删除 **34 个文件 / 3,084 行**,需逐一确认哪些保留、哪些裁剪,避免误删 typecheck 等必要 CI | +| Plain Terminal 兼容 | 高 | **全链路改造**:检测 → 渲染配置 → 主题系统 → 组件层 → 路由层 → 7 种语言 i18n,6 个文件联动 | +| `.mimocode/` 定制系统 | 中 | 新增 **34 文件 / 3,109 行**,含 17 种语言术语表、937 行 TUI 插件、899 行 Agent 指令 | +| 安装/升级重构 | 中 | registry 从硬编码改为动态检测,curl 安装从 TODO 注释恢复为可用代码,需验证多包管理器路径 | +| 发布脚本拆分 | 低 | 逻辑清晰但需确保 `release.ts` → `publish.ts` → `version.ts` 链路完整 | +| Checkpoint 注释脱敏 | 低 | 6 处 `cc-haha` 替换,纯文本替换 | + +### 核心瓶颈 + +1. **跨模块联动验证**: Plain Terminal 模式影响 renderer → theme → component → route → i18n 全栈,每层都要适配降级逻辑,且不能影响正常终端体验 +2. **开源裁剪的边界判断**: CI 工作流哪些是内部专用(删)、哪些是开源协作必需(留),需要逐个 review 34 个 workflow 文件 +3. **安装链路安全**: npm registry 从内网切到公网 + curl 安装恢复,涉及用户机器上的代码执行,必须确保 URL 正确、fallback 合理 +4. **品牌一致性**: 包名、仓库 URL、安装脚本 URL、README 链接——散布在 ~10 个文件中,任何一处不一致都会导致用户安装失败或文档指向错误 + +--- + +## 四、Commit 时间线 + +| Hash | 日期 | 作者 | 说明 | +|------|------|------|------| +| `7233b71` | 2026-06-11 | qiaozongming | Initial open-source release of MiMo Code(主体变更) | +| `4ef01a4` | 2026-06-11 | — | readme | +| `251e207` | 2026-06-11 | zhangchuanfeng | docs: correct OpenCode repository URL in README files | +| `6ce77f0` | 2026-06-11 | shenbowen1 | Update README | +| `9753077` | 2026-06-11 | bwshen-mi | Merge PR #20 | +| `bc9546e` | 2026-06-11 | — | docs: update community group chat QR code | +| `e96727a` | 2026-06-11 | — | Merge PR #149 | + +--- + +## 五、未提交的本地变更(工作区) + +以下文件在工作区但未入库(`git status` 显示 `??`): + +| 路径 | 推测用途 | +|------|----------| +| `packages/opencode/tmp-distill.mjs` | Distill 临时输出 | + +--- + +## 六、对话循环 Bug 分析:项目对话进入无限循环无法退出 + +### 问题现象 + +用户发起对话后,AI 在完成回答后不会返回空闲状态,而是不断重新进入 LLM 调用循环,TUI 界面始终显示"运行中",用户无法输入新消息。 + +### 涉及的三个循环层级 + +MiMo Code 的对话执行路径由 **三层嵌套的 `while(true)` 循环** 组成,任何一层的退出条件失效都会导致无限循环: + +#### 层级 1:主 Session 循环 (`session/prompt.ts` `runLoop`) + +``` +位置: packages/opencode/src/session/prompt.ts, 行 ~2050-2932 +``` + +这是最外层循环。每次用户发消息后进入,负责: + +1. 读取消息历史 (`msgs`) +2. 分类上一步 assistant 输出 (`classifyAssistantStep`) +3. 检查是否需要继续(有 tool call → continue,final → break) +4. 处理 overflow/compaction +5. 调用 LLM (`handle.process`) +6. 根据返回结果决定 `continue` 或 `break` + +**退出条件**(任一满足即 break): + +| 条件 | 代码位置 | 说明 | +|------|----------|------| +| `classification.type === "final"` | L2236-2240 | assistant 输出了文本且 finish=stop | +| `classification.type === "filtered"` | L2218-2221 | 内容被安全过滤器拦截 | +| `classification.type === "failed"` | L2223-2226 | 模型返回错误 | +| `classification.type === "think-only"/"invalid"` | L2228-2232 | 只有思考没有输出,且 autoContinue 拒绝 | +| `taskGate` 不要求 re-entry | L2927 | 任务门检查通过 | +| `goalGate` 不要求 re-entry | L2928 | 目标门检查通过 | + +**可能导致无限循环的路径**: + +- `classification.type === "continue"` 且 tool call 永远不完成(例如 tool 执行超时但 state 没变成 `error`) +- `taskGate` 不断返回 `needReentry: true`(未完成任务列表未清空,且未达到 `MAX_TASK_GATE_MAIN_REACT` 上限) +- `goalGate` 不断返回 `!verdict.ok`(目标评估模型反复认为目标未达成,且未达到 `MAX_GOAL_REACT` 上限) +- `overflow` → `compaction.create()` → `continue`,但 compaction 失败后 context 仍然溢出 + +#### 层级 2:Actor preStop ReAct 循环 (`actor/spawn.ts` `forkWork`) + +``` +位置: packages/opencode/src/actor/spawn.ts, 行 321-388 +``` + +当子 agent(subagent/peer)完成一个 turn 后,`plugin.triggerActorPreStop` 检查是否需要重新执行: + +```ts +while (true) { + // ... runTurn ... + iteration++ + if (iteration > MAX_PRE_REACT) break // 硬上限 + const decision = yield* plugin.triggerActorPreStop({...}) + if (!decision.continue) break + if (!decision.reason) break // defense-in-depth + // ... 重新执行 ... +} +``` + +**退出条件**: + +| 条件 | 说明 | +|------|------| +| `iteration > MAX_PRE_REACT` | 硬上限保护 | +| `!decision.continue` | 插件决定不再继续 | +| `!decision.reason` | 防御性检查 | + +**可能无限循环**:如果 `MAX_PRE_REACT` 值过大,且插件始终返回 `continue: true`。 + +#### 层级 3:Actor postStop ReAct 循环 (`actor/spawn.ts` `forkWork`) + +``` +位置: packages/opencode/src/actor/spawn.ts, 行 488-567 +``` + +与 preStop 结构类似,但在 actor 完成并交付结果后运行: + +```ts +while (true) { + const decision = yield* plugin.triggerActorPostStop({...}) + if (!decision.continue) break + if (!decision.reason) break + if (postIter >= MAX_POST_REACT) break // 硬上限 + // ... runTurn ... + if (newTurn.finalText === undefined) break +} +``` + +**退出条件**: + +| 条件 | 说明 | +|------|------| +| `postIter >= MAX_POST_REACT` | 硬上限保护 | +| `!decision.continue` | 插件决定不再继续 | +| `!decision.reason` | 防御性检查 | +| `newTurn.finalText === undefined` | runTurn 失败或无输出 | + +### 循环退出的安全网机制 + +代码中已实现多层安全网防止真正的无限循环: + +| 安全网 | 位置 | 说明 | +|--------|------|------| +| `MAX_PRE_REACT` | spawn.ts L344 | preStop 循环硬上限 | +| `MAX_POST_REACT` | spawn.ts L508 | postStop 循环硬上限 | +| `MAX_TASK_GATE_MAIN_REACT` | prompt.ts L1830 | taskGate 重入上限 | +| `MAX_GOAL_REACT` | prompt.ts L1926 | goalGate 重入上限 | +| `agent.steps` | prompt.ts L2512 | agent 配置的步数上限 | +| `REPEATED_STEP_THRESHOLD = 3` | prompt.ts L109 | 重复步骤检测 | +| `overflow → compaction` | prompt.ts L2420-2500 | 上下文溢出时压缩 | +| `classifyAssistantStep` | classify.ts L31-92 | 统一的步骤分类器 | + +### 核心分类逻辑 (`classify.ts`) + +```ts +// 优先级从高到低: +// 1. 有待处理的 tool call → continue(必须重入以发送 tool 结果) +// 2. assistant 未完成 → continue +// 3. finish = "tool-calls" → continue +// 4. 过时的 assistant → continue +// 5. error → failed +// 6. structured output / summary → final +// 7. content-filter → filtered +// 8. 有非空文本 → final +// 9. 只有推理 → think-only +// 10. 空输出 → invalid +``` + +**关键陷阱**:步骤 1 中 `part.state.status !== "error"` 的检查——如果 tool 执行超时但 state 仍为 `running` 而非 `error`,分类器会一直返回 `continue`,导致循环永不退出。 + +### TUI 层面的交互控制 + +``` +位置: packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +``` + +| 信号 | 作用 | +|------|------| +| `visible()` (L139-144) | 控制 Prompt 输入框是否显示:`!session().parentID && currentAgentID() === "main" && permissions().length === 0 && questions().length === 0` | +| `disabled()` (L146) | 控制输入框是否禁用:`permissions().length > 0 || questions().length > 0` | +| `pending()` (L148-150) | 最后一条未完成的 assistant 消息 ID | + +**用户体验**:当后端循环持续运行时,`pending()` 始终有值,TUI 持续显示 spinner/进度。Prompt 输入框虽然 `visible` 且非 `disabled`,但用户发的新消息会被排入队列,直到当前循环结束才会被处理——这给用户"不能退出"的感觉。 + +### 所有安全网常量汇总 + +| 常量 | 值 | 位置 | 说明 | +|------|-----|------|------| +| `MAX_PRE_REACT` | 3 | `actor/spawn.ts` L32 | subagent preStop 循环硬上限 | +| `MAX_POST_REACT` | 3 | `actor/spawn.ts` L34 | subagent postStop 循环硬上限 | +| `MAX_TASK_GATE_MAIN_REACT` | 3 | `task/gate.ts` L20 | 主循环 taskGate 重入上限 | +| `MAX_GOAL_REACT` | 12 | `session/prompt.ts` L102 | 主循环 goalGate 重入上限 | +| `OUTPUT_LENGTH_CONTINUATION_LIMIT` | 3 | `flag/flag.ts` L63 | 输出截断后自动继续上限(可环境变量覆盖) | +| `INVALID_OUTPUT_CONTINUATION_LIMIT` | 2 | `flag/flag.ts` L64 | 空/仅推理输出自动继续上限(可环境变量覆盖) | +| `REPEATED_STEP_THRESHOLD` | 3 | `session/prompt.ts` L109 | 连续相同步骤检测阈值 | +| `agent.steps` | 配置值 | `session/prompt.ts` L2512 | agent 配置的步数上限 | + +**关键发现**:每个安全网**独立计数**,不共享。一个循环中可能先触发 `OUTPUT_LENGTH_CONTINUATION_LIMIT`(3次) → 然后 `taskGate`(3次) → 然后 `goalGate`(12次) → 再触发 `INVALID_OUTPUT_CONTINUATION_LIMIT`(2次),**总计最多 3+3+12+2 = 20 轮额外循环**,加上正常的多步 tool call,用户实际等待时间可能极长。 + +### 根因分析 + +**Bug 不是"无限循环"而是"超长循环"**——代码中确实不存在数学意义上的无限循环(每个 `while(true)` 都有硬上限 break),但用户体验等价于无限循环: + +1. **各安全网上限累加**:`taskGate`(3) + `goalGate`(12) + `autoContinueInvalidOutput`(2) + `autoContinueOutputLength`(3) = 最多 **20 轮重入**,每轮都包含 LLM API 调用(数秒到数十秒),总等待可达 **5-20 分钟** +2. **tool call 超时未转 error**:`classifyAssistantStep` 的第一条规则是"有待处理 tool call → continue",检查条件是 `part.state.status !== "error"`。如果 tool 执行卡住但 state 仍为 `running`(而非 `error`),分类器永远返回 `continue`,**无硬上限保护**——只有 `agent.steps` 配置值能兜底,但默认值可能很大 +3. **goalGate 的 judge 模型失败是 fail-open**:`goal.evaluate` 出错时返回 `{ ok: true }`(允许停止),但正常评估反复返回 `{ ok: false }` 时,judge 本身可能给出不一致结论(同一段对话,judge 先说"未完成"再说"未完成"),12 次重入全部消耗 +4. **TUI 中断传播链路完整但用户不知道**:按两次 Escape → `sdk.client.session.abort()` → HTTP POST `/:sessionID/abort` → `SessionPrompt.Service.cancel()` → `SessionRunState.cancel()` → `Runner.cancel()` → `Fiber.interrupt(run.fiber)`。链路完整,但 TUI 只显示 `esc interrupt` / `esc again to interrupt` 小字,用户不知道需要按两次 + +### Runner 中断传播机制 + +``` +文件: packages/opencode/src/effect/runner.ts +``` + +`Runner` 是 Effect 的 Fiber 管理器,核心状态机: + +| 状态 | 含义 | +|------|------| +| `Idle` | 无运行中的任务 | +| `Running` | 有一个 fiber 正在执行 | +| `Shell` | 有一个 shell fiber 正在执行 | +| `ShellThenRun` | shell 执行中,有 pending 任务等待 shell 完成后执行 | + +**`cancel()` 实现**(L166-197): + +```ts +cancel = SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": return [Effect.void, st] + case "Running": return [Fiber.interrupt(st.run.fiber) ... , { _tag: "Idle" }] + case "Shell": return [stopShell(st.shell) ... , { _tag: "Idle" }] + case "ShellThenRun": return [ + Deferred.fail(st.run.done, new Cancelled()) // 取消 pending 任务 + stopShell(st.shell) // 中断 shell + idleIfCurrent() + , { _tag: "Idle" }] + } +}) +``` + +**关键**:`cancel` → `Fiber.interrupt` 会向 Effect fiber 发送中断信号,fiber 中的 `Effect.gen` 会在下一个 yield 点检查中断标志并退出。这意味着如果 `runLoop` 正在 `yield* handle.process()`(等待 LLM API 响应),中断会在 API 调用返回后的下一个 yield 点生效。 + +**但**:如果 LLM API 调用本身阻塞了 60 秒,用户在这 60 秒内按 Escape 的 `cancel` 只能等 API 返回后才能中断——这 60 秒内用户看到的是"按了 Escape 但没反应"。 + +### TUI Escape 中断代码路径 + +``` +文件: packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx, 行 560-581 +``` + +```ts +// 第一次按 Escape +setStore("interrupt", store.interrupt + 1) +setTimeout(() => { + setStore("interrupt", 0) // 5 秒超时重置 +}, 5000) + +// 第二次按 Escape(5 秒内) +if (store.interrupt >= 2) { + void sdk.client.session.abort({ + sessionID: props.sessionID, + }) + setStore("interrupt", 0) +} +``` + +**UI 显示**(L1756-1761): + +```tsx + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + +``` + +**问题**: +1. 提示太小(仅小字 `esc interrupt`),用户可能没注意到 +2. 5 秒超时太短——如果用户第一次按 Escape 后 5 秒内没按第二次,计数器重置 +3. `sdk.client.session.abort()` 是异步 HTTP 调用,如果后端正在处理 LLM 响应,abort 请求可能因为 Fiber 中断延迟而无法立即生效 + +### 完整的中断传播链路 + +``` +用户按 Escape (第1次) + → store.interrupt = 1, 5秒超时计时器启动 + → UI 显示 "esc again to interrupt" + +用户按 Escape (第2次, 5秒内) + → store.interrupt >= 2 + → sdk.client.session.abort({ sessionID }) + → HTTP POST /sessions/:sessionID/abort + → SessionRoutes.abort handler + → SessionPrompt.Service.cancel(sessionID) + → SessionRunState.cancel(sessionID) + → Runner.cancel() + → Fiber.interrupt(run.fiber) + → Effect fiber 在下一个 yield 点退出 + → Runner 状态变为 Idle + → status.set(sessionID, { type: "idle" }) + → TUI 显示 idle 状态 +``` + +### 修复建议(优先级排序) + +| 优先级 | 修复 | 涉及文件 | 改动量 | 说明 | +|--------|------|----------|--------|------| +| P0 | tool call 超时强制设为 `error` | `tool/` 执行层 | 小 | 消除 `classifyAssistantStep` 的"永远 continue"路径 | +| P0 | runLoop 增加全局步数硬上限 | `prompt.ts` runLoop | 小 | 如 `MAX_TOTAL_STEPS = 50`,无论什么路径触发,总步数超限强制 break | +| P1 | TUI 中断提示增强 | `prompt/index.tsx` | 小 | 将 `esc interrupt` 改为更醒目的提示,增加超时到 10 秒 | +| P1 | abort 后立即设 `status: idle` | `run-state.ts` | 小 | 防止 Fiber.interrupt 延迟导致 UI 仍显示 busy | +| P2 | `MAX_GOAL_REACT` 降至 5 | `prompt.ts` | 一行 | 12 次重入太多,5 次足够覆盖正常场景 | +| P2 | goalGate 失败计数器持久化 | `prompt.ts` goalGate | 中 | 当前 react 计数器在 session 重启后重置 | + +### 为什么分析这么久 + +1. **三层循环嵌套**:runLoop → preStop loop → postStop loop,退出逻辑分散在 3 个文件、1000+ 行代码中 +2. **隐式状态依赖**:退出条件依赖 DB 中的 task 状态、goal 评估模型返回、tool part 的 state 字段——不是简单的布尔判断 +3. **安全网看似完备但累加过大**:每个循环都有硬上限,但各上限独立计数、可累加(3+3+12+2=20轮),且 tool call 超时路径无硬上限——用户等不到上限触发就强制关闭了 +4. **中断传播链路完整但隐蔽**:从 TUI Escape → HTTP abort → Fiber.interrupt 的链路是完整的,但用户不知道需要按两次 Escape,5 秒超时后计数器又重置,且 LLM API 阻塞期间中断无法立即生效 + +--- + +## 七、对话循环 Bug 修复实施 + +> **日期**: 2026-06-11 +> **状态**: 已实施,待验证 + +### 修复概览 + +基于第六节的根因分析和修复建议,已实施三项 P0/P2 级别代码修复: + +| # | 优先级 | 修复内容 | 文件 | 改动 | +|---|--------|----------|------|------| +| 1 | P0 | `runLoop` 全局步数硬上限 `MAX_TOTAL_STEPS = 50` | `prompt.ts` | +12 行 | +| 2 | P0 | `classifyAssistantStep` 陈旧 tool call 兜底 | `classify.ts` | +17 行 | +| 3 | P2 | `MAX_GOAL_REACT` 从 12 降至 5 | `prompt.ts` | 1 行 | + +### 修复 1:runLoop 全局步数硬上限 + +**问题**:`runLoop` 的 `while(true)` 无全局步数上限。各路径(taskGate/goalGate/autoContinue/overflow)的计数器独立运作,累加后可达 20+ 轮重入。每轮涉及一次 LLM API 调用(耗时数秒),用户感知为"无限循环"。 + +**修改**: + +```ts +// prompt.ts,新增常量(L111-121) +const MAX_TOTAL_STEPS = 50 + +// prompt.ts,runLoop 循环入口(L2132-2139) +while (true) { + if (step >= MAX_TOTAL_STEPS) { + yield* slog.warn("runLoop hit MAX_TOTAL_STEPS; forcing break", { step, MAX_TOTAL_STEPS }) + break + } + // ... 原有逻辑 +} +``` + +**设计决策**: +- 上限设为 50:足够覆盖合法的长时间 agent 任务(典型 5-15 步),但保证最坏情况下约 2-3 分钟内终止 +- 检查放在循环最顶部,确保无论进入路径如何都会被拦截 +- `slog.warn` 记录便于事后诊断 + +### 修复 2:classifyAssistantStep 陈旧 tool call 兜底 + +**问题**:`classifyAssistantStep` 规则 #1 对 `state.status !== "error"` 的 tool part 返回 `continue`,但 `running` 状态的 tool part 如果执行器已崩溃/超时/被杀,永远不会转为 `error`,导致 classify 永远返回 `continue`,runLoop 永远无法退出。 + +**修改**: + +```ts +// classify.ts,新增参数(L38-44) +staleToolCallMs?: number // 默认 5 分钟 = 300_000 ms + +// classify.ts,规则 #1 的 .some() 增加陈旧检查(L64-70) +input.parts.some( + (part) => + part.type === "tool" && + !part.metadata?.providerExecuted && + part.state.status !== "error" && + // P0 fix: 排除陈旧的 running tool part + !(part.state.status === "running" && staleMs > 0 && + assistant.time?.created && now - assistant.time.created > staleMs), +) +``` + +**设计决策**: +- 默认超时 5 分钟:覆盖 LLM API 调用、工具执行、网络波动的正常时长,但不让用户等太久 +- 向后兼容:`staleToolCallMs` 为可选参数,不传则使用默认值 +- 检查 `assistant.time?.created`:确保有时间戳可比较,无时间戳时不触发(保守策略) + +### 修复 3:MAX_GOAL_REACT 降低 + +**问题**:`MAX_GOAL_REACT = 12` 允许 goalGate 驱动 12 次重入,每次重入可能触发 LLM API 调用 + 工具执行,12 轮总计可能 2-3 分钟,且 goalGate 评估本身也有 token 消耗。 + +**修改**: + +```ts +// prompt.ts L102 +const MAX_GOAL_REACT = 5 // 原值 12 +``` + +**设计决策**: +- 5 次足够覆盖正常场景(通常 2-3 次 goalGate 重入即可完成目标) +- 配合修复 1 的全局上限,即使 goalGate 计数器异常,也会被 MAX_TOTAL_STEPS 拦截 + +### 修复效果预估 + +| 场景 | 修复前 | 修复后 | +|------|--------|--------| +| tool call 挂起(executor 崩溃) | 无限循环 | 5 分钟后 classify 跳过 stale part,或 50 步后 runLoop break | +| goalGate 反复不满足 | 最多 12 轮重入 | 最多 5 轮重入 | +| 多路径累加(taskGate + goalGate + autoContinue + overflow) | 累加可达 20+ 轮 | 全局上限 50 步强制终止 | +| 用户按 Escape 中断 | Fiber.interrupt 延迟可能无效 | 即使中断延迟,runLoop 也会在 50 步内自行终止 | + +### 未实施的修复(后续跟进) + +| 优先级 | 修复 | 原因 | +|--------|------|------| +| P1 | TUI 中断提示增强 | 需 UI 设计确认,非逻辑修复 | +| P1 | abort 后立即设 `status: idle` | 需评估对其他状态的影响 | +| P2 | goalGate 失败计数器持久化 | 涉及 DB schema 变更 | + +--- + +## 八、对话耗时显示功能 + +> **日期**: 2026-06-11 +> **状态**: 已实施,类型检查通过 + +### 问题背景 + +用户在 TUI 中发起对话后,状态栏只显示一个 spinner + "busy" 文本(可选 `message`)。用户无法直观判断: + +1. 对话是否还在进行中(spinner 动画可能在某些终端上不明显) +2. 已经等了多久(是否需要中断重试) +3. 对话是否"卡住了"(耗时异常长说明可能有无限循环) + +### 解决方案 + +在 TUI prompt 组件的 busy 状态指示器旁,显示自进入 busy 以来的经过时间(如 `12s`、`1m 30s`、`2h 15m`)。 + +### 修改的文件 + +| # | 文件 | 变更 | 说明 | +|---|------|------|------| +| 1 | `packages/opencode/src/session/status.ts` | +7 行 | `busy` 类型新增 `startedAt?: number` 字段;`set()` 方法在转入 busy 时自动打时间戳 | +| 2 | `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` | +20 行 | 读取 `startedAt`,每秒计算 elapsed 并用 `formatDuration()` 渲染 | +| 3 | `packages/sdk/js/src/gen/types.gen.ts` | +1 行 | v1 SDK `SessionStatus.busy` 类型新增 `startedAt?: number` | +| 4 | `packages/sdk/js/src/v2/gen/types.gen.ts` | +1 行 | v2 SDK `SessionStatus.busy` 类型新增 `startedAt?: number` | +| 5 | `packages/sdk/openapi.json` | +3 行 | OpenAPI schema `SessionStatus.busy` 新增 `startedAt` 字段 | + +### 详细修改 + +#### 1. `status.ts` — 自动记录 `startedAt` 时间戳 + +```ts +// Zod schema 变更 +z.object({ + type: z.literal("busy"), + message: z.string().optional(), + startedAt: z.number().optional(), // ← 新增 +}), + +// set() 方法中自动打时间戳 +const set = Effect.fn("SessionStatus.set")(function* (sessionID, status) { + const data = yield* InstanceState.get(state) + // When transitioning to busy, stamp startedAt so the TUI can show elapsed time. + // Preserve the existing startedAt if the caller is re-setting busy (e.g. runLoop + // sets busy on each iteration) so the timer doesn't reset mid-turn. + if (status.type === "busy" && status.startedAt === undefined) { + const existing = data.get(sessionID) + status = { + ...status, + startedAt: existing?.type === "busy" && existing.startedAt + ? existing.startedAt + : Date.now(), + } + } + // ... 原有逻辑 +}) +``` + +**设计决策**: + +- **自动打时间戳**:调用方不需要手动传 `startedAt`,`set()` 方法在首次进入 busy 时自动用 `Date.now()` 赋值 +- **保留已有值**:`runLoop` 每次迭代都会 `set({ type: "busy" })`,如果每次都重置 `startedAt`,计时器会在每一轮循环重置,用户看到的时间会不断归零。通过检查已有状态,只在首次 idle→busy 转换时设置 +- **可选字段**:`startedAt` 是 `optional`,保证向后兼容——旧版客户端不传此字段也不影响 + +#### 2. `index.tsx` — TUI 渲染耗时 + +```tsx +// 从 status 中提取 startedAt +const busyStartedAt = createMemo(() => { + const s = status() + return s.type === "busy" ? s.startedAt : undefined +}) + +// 每秒更新 elapsed +const [elapsed, setElapsed] = createSignal(0) +onMount(() => { + const timer = setInterval(() => { + const started = busyStartedAt() + if (started) setElapsed(Math.floor((Date.now() - started) / 1000)) + }, 1000) + onCleanup(() => clearInterval(timer)) +}) + +// 渲染:在 spinner 和 message 后追加耗时 + 0}> + {formatDuration(elapsed())} + +``` + +**UI 效果**: + +| 状态 | 修复前 | 修复后 | +|------|--------|--------| +| 刚开始处理 | `⠋ thinking…` | `⠋ thinking…` | +| 12 秒后 | `⠋ thinking…` | `⠋ thinking… 12s` | +| 1 分 30 秒后 | `⠋ thinking…` | `⠋ thinking… 1m 30s` | +| 2 小时后 | `⠋ thinking…` | `⠋ thinking… 2h 0m` | + +**复用已有函数**:`formatDuration()` 已在 `@/util/format` 中存在,用于 retry 倒计时显示,无需新增任何格式化逻辑。 + +#### 3. SDK 类型同步 + +`SessionStatus` 类型定义在三处: + +| 位置 | 用途 | +|------|------| +| `packages/opencode/src/session/status.ts` | 后端 Zod schema(真实来源) | +| `packages/sdk/js/src/gen/types.gen.ts` | v1 SDK TypeScript 类型 | +| `packages/sdk/js/src/v2/gen/types.gen.ts` | v2 SDK TypeScript 类型 | +| `packages/sdk/openapi.json` | OpenAPI 3.1 schema(SDK 生成源头) | + +三处都需要添加 `startedAt?: number`,否则前端 SDK 消费时会报类型错误。 + +### 为什么处理这么久 + +| 阶段 | 耗时 | 原因 | +|------|------|------| +| 探索 TUI 状态管理 | 中 | 需要理解 `sync.data.session_status` → `status` memo → 渲染的完整数据流 | +| 确定 `startedAt` 注入位置 | 中 | 需要找到"状态转入 busy"的确切位置(`status.ts` 的 `set()` 方法),并处理 runLoop 重复 set busy 的时间戳保留问题 | +| SDK 类型同步 | 低 | 三处类型文件 + OpenAPI schema 需要手动同步,遗漏任何一处都会编译失败 | +| 类型检查验证 | 低 | `bun typecheck` 通过,仅有无关的临时测试文件报错 | + +**核心难点**:不是"加一个计时器"这么简单——需要确保: + +1. **时间戳不重置**:`runLoop` 的 `while(true)` 每轮迭代都会 `set({ type: "busy" })`,如果每次都重新 `Date.now()`,用户看到的耗时会在每一轮 LLM 调用后归零 +2. **状态转换正确**:idle→busy 打时间戳,busy→busy 保留原值,busy→idle 清除 +3. **跨 SDK 版本一致**:v1/v2 SDK + OpenAPI schema 三处类型必须同步 diff --git a/packages/opencode/src/agent/prompt/distill.txt b/packages/opencode/src/agent/prompt/distill.txt index 912ead66..bb0458e8 100644 --- a/packages/opencode/src/agent/prompt/distill.txt +++ b/packages/opencode/src/agent/prompt/distill.txt @@ -1,9 +1,18 @@ # Distill: Workflow Packaging +> 语言配置:`zh.ts` — 全程中文模式 + You look back over recent work, identify repeated manual workflows worth packaging, and turn only the high-confidence ones into reusable assets: skills, custom subagents, commands, or recurring playbooks. +**Stop condition**: Distill runs until all phases complete AND the output +summary is produced. If no repeated workflows are found after investigation, +that is a valid stop — report "Nothing found" and clear the goal. + +**Goal clear**: After the summary is delivered, automatically clear the distill +goal (`/goal clear`) to signal completion. + Default window: review the last 30 days of sessions, or all available history if shorter. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 11263232..4db0003c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -360,7 +360,7 @@ function App(props: { onSnapshot?: () => Promise }) { if (!providerID || !modelID) return toast.show({ variant: "warning", - message: `Invalid model format: ${args.model}`, + message: t("tui.error.invalid_model", { model: args.model }), duration: 3000, }) local.model.set({ providerID, modelID }, { recent: true }) @@ -388,7 +388,7 @@ function App(props: { onSnapshot?: () => Promise }) { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) } else { - toast.show({ message: "Failed to fork session", variant: "error" }) + toast.show({ message: t("tui.error.fork_failed"), variant: "error" }) } }) } else { @@ -408,7 +408,7 @@ function App(props: { onSnapshot?: () => Promise }) { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) } else { - toast.show({ message: "Failed to fork session", variant: "error" }) + toast.show({ message: t("tui.error.fork_failed"), variant: "error" }) } }) }) @@ -814,7 +814,7 @@ function App(props: { onSnapshot?: () => Promise }) { const files = await props.onSnapshot?.() toast.show({ variant: "info", - message: `Heap snapshot written to ${files?.join(", ")}`, + message: t("tui.heap_snapshot.written", { files: files?.join(", ") ?? "?" }), duration: 5000, }) dialog.clear() @@ -940,7 +940,7 @@ function App(props: { onSnapshot?: () => Promise }) { route.navigate({ type: "home" }) toast.show({ variant: "info", - message: "The current session was deleted", + message: t("tui.session.deleted"), }) } }) @@ -965,8 +965,8 @@ function App(props: { onSnapshot?: () => Promise }) { const choice = await DialogConfirm.show( dialog, - `Update Available`, - `A new release v${version} is available. Would you like to update now?`, + t("tui.update.available.title"), + t("tui.update.available.message", { version }), "skip", ) @@ -979,7 +979,7 @@ function App(props: { onSnapshot?: () => Promise }) { toast.show({ variant: "info", - message: `Updating to v${version}...`, + message: t("tui.update.in_progress", { version }), duration: 30000, }) @@ -988,8 +988,8 @@ function App(props: { onSnapshot?: () => Promise }) { if (result.error || !result.data?.success) { toast.show({ variant: "error", - title: "Update Failed", - message: "Update failed", + title: t("tui.update.failed.title"), + message: t("tui.update.failed.message"), duration: 10000, }) return @@ -997,8 +997,8 @@ function App(props: { onSnapshot?: () => Promise }) { await DialogAlert.show( dialog, - "Update Complete", - `Successfully updated to MiMoCode v${result.data.version}. Please restart the application.`, + t("tui.update.complete.title"), + t("tui.update.complete.message", { version: result.data.version }), ) void exit() @@ -1011,7 +1011,7 @@ function App(props: { onSnapshot?: () => Promise }) { const id = typeof props.id === "string" ? props.id : undefined const command = typeof props.command === "string" ? props.command : undefined const cwd = typeof props.cwd === "string" ? props.cwd : undefined - const description = typeof props.description === "string" ? props.description : "(interactive)" + const description = typeof props.description === "string" ? props.description : t("tui.bash.interactive") const env = props.env && typeof props.env === "object" ? (props.env as Record) : undefined if (!id || !command || !cwd) return @@ -1065,7 +1065,7 @@ function App(props: { onSnapshot?: () => Promise }) { } catch (retryErr: any) { toast.show({ variant: "error", - message: `Interactive command reply failed: ${retryErr?.message ?? "unknown"}`, + message: t("tui.error.interactive_reply_failed", { error: retryErr?.message ?? "unknown" }), }) } } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index c9c94a45..58dcaf50 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -14,6 +14,7 @@ import { useKeyboard } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import { useToast, type ToastContext } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" +import { useLanguage } from "@tui/context/language" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -254,12 +255,13 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() + const t = useLanguage().t useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url Clipboard.copy(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .then(() => toast.show({ message: t("tui.toast.copied_to_clipboard"), variant: "info" })) .catch(toast.error) } }) @@ -285,16 +287,16 @@ function AutoMethod(props: AutoMethodProps) { {props.title} dialog.clear()}> - esc + {t("tui.prompt.hint.esc")} {props.authorization.instructions} - Waiting for authorization... + {t("tui.dialog.provider.auto.waiting")} - c copy + c {t("tui.dialog.provider.auto.copy")} ) @@ -312,11 +314,12 @@ function CodeMethod(props: CodeMethodProps) { const sync = useSync() const dialog = useDialog() const [error, setError] = createSignal(false) + const t = useLanguage().t return ( { const { error } = await sdk.client.provider.oauth.callback({ providerID: props.providerID, @@ -336,7 +339,7 @@ function CodeMethod(props: CodeMethodProps) { {props.authorization.instructions} - Invalid code + {t("tui.dialog.provider.code.invalid")} )} @@ -354,18 +357,18 @@ function ApiMethod(props: ApiMethodProps) { const sdk = useSDK() const sync = useSync() const { theme } = useTheme() + const t = useLanguage().t return ( - OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API - key. + {t("tui.dialog.provider.api.description.opencode")} Go to https://opencode.ai/zen to get a key @@ -375,8 +378,7 @@ function ApiMethod(props: ApiMethodProps) { "opencode-go": ( - OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models - with generous usage limits. + {t("tui.dialog.provider.api.description.opencode-go")} Go to https://opencode.ai/zen and enable OpenCode Go diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx index b12b37ae..bf25ed57 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -3,6 +3,7 @@ import { createResource, createMemo } from "solid-js" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "@tui/context/sdk" import { useLocal } from "@tui/context/local" +import { useLanguage } from "@tui/context/language" export type DialogSkillProps = { onSelect: (skill: string) => void @@ -12,6 +13,7 @@ export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() const local = useLocal() + const t = useLanguage().t dialog.setSize("large") const [skills] = createResource(async () => { @@ -38,5 +40,5 @@ export function DialogSkill(props: DialogSkillProps) { })) }) - return + return } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 72019298..9501635e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -4,6 +4,7 @@ import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" +import { useLanguage } from "@tui/context/language" export type DialogStatusProps = {} @@ -11,6 +12,7 @@ export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() + const t = useLanguage().t const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index c74d3bbc..506fc1b1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -5,6 +5,7 @@ import { createSignal } from "solid-js" import { InstallationVersion } from "@/installation/version" import { win32FlushInputBuffer } from "../win32" import { getScrollAcceleration } from "../util/scroll" +import { useLanguage } from "@tui/context/language" export function ErrorComponent(props: { error: Error @@ -30,6 +31,7 @@ export function ErrorComponent(props: { } }) const [copied, setCopied] = createSignal(false) + const t = useLanguage().t const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml") @@ -65,22 +67,22 @@ export function ErrorComponent(props: { - Please report an issue. + {t("tui.error.report_issue")} - Copy issue URL (exception info pre-filled) + {t("tui.error.copy_url")} - {copied() && Successfully copied} + {copied() && {t("tui.error.copied")}} - A fatal error occurred! + {t("tui.error.fatal")} - Reset TUI + {t("tui.error.reset_tui")} - Exit + {t("tui.error.exit")} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 85ffb967..f5dd02eb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -10,6 +10,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" +import { useLanguage } from "@tui/context/language" import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util" @@ -84,6 +85,7 @@ export function Autocomplete(props: { const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() + const lang = useLanguage() const [store, setStore] = createStore({ index: 0, @@ -644,7 +646,7 @@ export function Autocomplete(props: { each={options()} fallback={ - No matching items + {lang.t("tui.autocomplete.no_matching")} } > diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d04725fa..ffdf71d3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1688,10 +1688,27 @@ export function Prompt(props: PromptProps) { const s = status() return s.type === "busy" ? s.message : undefined }) + const busyStartedAt = createMemo(() => { + const s = status() + return s.type === "busy" ? s.startedAt : undefined + }) + const [elapsed, setElapsed] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const started = busyStartedAt() + if (started) setElapsed(Math.floor((Date.now() - started) / 1000)) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) return ( - - {busyMessage()} - + <> + + {busyMessage()} + + 0}> + {formatDuration(elapsed())} + + ) })()} @@ -1725,11 +1742,18 @@ export function Prompt(props: PromptProps) { clearInterval(timer) }) }) + const retryMessageMap: Record = { + "Too Many Requests": t("tui.error.too_many_requests"), + "Provider is overloaded": t("tui.error.provider_overloaded"), + "Rate Limited": t("tui.error.rate_limited"), + "Transient network error": t("tui.error.transient_network"), + } + const translateRetryMessage = (msg: string) => retryMessageMap[msg] ?? msg const handleMessageClick = () => { const r = retry() if (!r) return if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) + void DialogAlert.show(dialog, t("tui.error.retry_title"), translateRetryMessage(r.message)) } } @@ -1737,9 +1761,11 @@ export function Prompt(props: PromptProps) { const r = retry() if (!r) return "" const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" + const truncatedHint = isTruncated() ? t("tui.prompt.truncated.expand") : "" const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` + const retryInfo = duration + ? ` [${t("tui.prompt.retry.retrying")} ${t("tui.prompt.retry.inDuration", { duration })} ${t("tui.prompt.retry.attempt", { attempt: r.attempt })}]` + : ` [${t("tui.prompt.retry.retrying")} ${t("tui.prompt.retry.attempt", { attempt: r.attempt })}]` return baseMessage + truncatedHint + retryInfo } @@ -1754,9 +1780,9 @@ export function Prompt(props: PromptProps) { 0 ? theme.primary : theme.text}> - esc{" "} + {t("tui.prompt.hint.esc")}{" "} 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + {store.interrupt > 0 ? t("tui.prompt.hint.interrupt_again") : t("tui.prompt.hint.interrupt")} @@ -1798,7 +1824,7 @@ export function Prompt(props: PromptProps) { - esc exit shell mode + esc {t("tui.shell.exit")} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx index 833e2db3..0243d53c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx @@ -1,11 +1,13 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@mimo-ai/plugin/tui" import { createMemo, Show } from "solid-js" import { Global } from "@/global" +import { useLanguage } from "@tui/context/language" const id = "internal:sidebar-footer" function View(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current + const t = useLanguage().t const has = createMemo(() => props.api.state.provider.some( (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0), @@ -42,18 +44,18 @@ function View(props: { api: TuiPluginApi }) { - Getting started + {t("tui.sidebar.getting_started")} props.api.kv.set("dismissed_getting_started", true)}> ✕ - MiMoCode includes free models so you can start immediately. + {t("tui.sidebar.free_models_desc")} - Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc + {t("tui.sidebar.connect_providers_desc")} - Connect provider + {t("tui.sidebar.connect_provider")} /connect diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx index 665ffb2c..8000a371 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx @@ -1,11 +1,13 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@mimo-ai/plugin/tui" import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js" +import { useLanguage } from "@tui/context/language" const id = "internal:sidebar-mcp" function View(props: { api: TuiPluginApi }) { const [open, setOpen] = createSignal(true) const theme = () => props.api.theme.current + const t = useLanguage().t const list = createMemo(() => props.api.state.mcp()) const on = createMemo(() => list().filter((item) => item.status === "connected").length) const bad = createMemo( @@ -34,11 +36,11 @@ function View(props: { api: TuiPluginApi }) { {open() ? "▼" : "▶"} - MCP + {t("tui.mcp.label")} {" "} - ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""}) + ({on()} {t("tui.mcp.active_count")}{bad() > 0 ? `, ${bad()} ${bad() > 1 ? t("tui.mcp.error_count_plural") : t("tui.mcp.error_count")}` : ""}) @@ -59,14 +61,14 @@ function View(props: { api: TuiPluginApi }) { {item.name}{" "} - Connected + {t("tui.mcp.connected")} {item.error} - Pending approval - Disabled - Needs auth - Needs client ID + {t("tui.mcp.pending")} + {t("tui.mcp.disabled")} + {t("tui.mcp.needs_auth")} + {t("tui.mcp.needs_client_id")} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index b40b0f51..54a173bc 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -11,14 +11,14 @@ const key = Keybind.parse("space").at(0) const add = Keybind.parse("shift+i").at(0) const tab = Keybind.parse("tab").at(0) -function state(api: TuiPluginApi, item: TuiPluginStatus) { +function state(api: TuiPluginApi, item: TuiPluginStatus, t: (key: string, params?: Record) => string) { if (!item.enabled) { - return disabled + return {t("tui.plugins.state.disabled")} } return ( - {item.active ? "active" : "inactive"} + {item.active ? t("tui.plugins.state.active") : t("tui.plugins.state.inactive")} ) } @@ -28,10 +28,10 @@ function source(spec: string) { return fileURLToPath(spec) } -function meta(item: TuiPluginStatus, width: number) { +function meta(item: TuiPluginStatus, width: number, t: (key: string, params?: Record) => string) { if (item.source === "internal") { - if (width >= 120) return "Built-in plugin" - return "Built-in" + if (width >= 120) return t("tui.plugins.meta.builtin_long") + return t("tui.plugins.meta.builtin") } const next = source(item.spec) if (next) return next @@ -41,6 +41,7 @@ function meta(item: TuiPluginStatus, width: number) { function Install(props: { api: TuiPluginApi }) { const [global, setGlobal] = createSignal(false) const [busy, setBusy] = createSignal(false) + const t = useLanguage().t useKeyboard((evt) => { if (evt.name !== "tab") return @@ -52,10 +53,10 @@ function Install(props: { api: TuiPluginApi }) { return ( ( scope: @@ -63,7 +64,7 @@ function Install(props: { api: TuiPluginApi }) { {global() ? "global" : "local"} - ({Keybind.toString(tab)} toggle) + {t("tui.plugins.scope.toggle", { key: Keybind.toString(tab) })} )} @@ -138,13 +139,13 @@ function Install(props: { api: TuiPluginApi }) { ) } -function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption { +function row(api: TuiPluginApi, item: TuiPluginStatus, width: number, t: (key: string, params?: Record) => string): DialogSelectOption { return { title: item.id, value: item.id, category: item.source === "internal" ? "Internal" : "External", - description: meta(item, width), - footer: state(api, item), + description: meta(item, width, t), + footer: state(api, item, t), disabled: item.id === id, } } @@ -158,6 +159,7 @@ function View(props: { api: TuiPluginApi }) { const [list, setList] = createSignal(props.api.plugins.list()) const [cur, setCur] = createSignal() const [lock, setLock] = createSignal(false) + const t = useLanguage().t createEffect(() => { const width = size().width @@ -180,7 +182,7 @@ function View(props: { api: TuiPluginApi }) { if (x !== y) return x - y return a.id.localeCompare(b.id) }) - .map((item) => row(props.api, item, size().width)), + .map((item) => row(props.api, item, size().width, t)), ) const flip = (x: string) => { @@ -206,13 +208,13 @@ function View(props: { api: TuiPluginApi }) { return ( setCur(item.value)} keybind={[ { - title: "toggle", + title: t("tui.plugins.view.toggle"), keybind: key, disabled: lock(), onTrigger: (item) => { @@ -221,7 +223,7 @@ function View(props: { api: TuiPluginApi }) { }, }, { - title: "install", + title: t("tui.plugins.view.install"), keybind: add, disabled: lock(), onTrigger: () => { diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index 81c4b679..d4ca3763 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -244,6 +244,158 @@ export const dict: Record = { "tui.dialog.prompt.processing": "processing...", "tui.dialog.prompt.submit_key": "enter", "tui.dialog.prompt.submit_action": "submit", + // Dialog message actions + "tui.dialog.message.title": "Message Actions", + "tui.dialog.message.revert.title": "Revert", + "tui.dialog.message.revert.description": "undo messages and file changes", + "tui.dialog.message.copy.title": "Copy", + "tui.dialog.message.copy.description": "message text to clipboard", + "tui.dialog.message.fork.title": "Fork", + "tui.dialog.message.fork.description": "create a new session", + + // Question prompt + "tui.question.answer.placeholder": "Type your own answer", + + // Permission dialog + "tui.permission.title": "Permission required", + "tui.permission.allow_once": "Allow once", + "tui.permission.allow_always": "Allow always", + "tui.permission.reject": "Reject", + "tui.permission.reject.title": "Reject permission", + "tui.permission.reject.placeholder": "Tell MiMoCode what to do differently", + "tui.permission.reject.confirm": "confirm", + "tui.permission.reject.cancel": "cancel", + "tui.permission.allow_always.title_single": "This will allow {{permission}} until MiMoCode is restarted.", + "tui.permission.allow_always.title_multi": "This will allow the following patterns until MiMoCode is restarted", + "tui.permission.allow_always.confirm": "Confirm", + "tui.permission.allow_always.cancel": "Cancel", + "tui.permission.no_diff": "No diff provided", + "tui.permission.edit_title": "Edit {{path}}", + "tui.permission.read_title": "Read {{path}}", + "tui.permission.path_label": "Path: {{path}}", + "tui.permission.glob_title": "Glob \"{{pattern}}\"", + "tui.permission.grep_title": "Grep \"{{pattern}}\"", + "tui.permission.pattern_label": "Pattern: {{pattern}}", + "tui.permission.list_title": "List {{path}}", + "tui.permission.shell_command": "Shell command", + "tui.permission.unknown": "Unknown", + "tui.permission.task_title": "{{type}} Task", + "tui.permission.webfetch_title": "WebFetch {{url}}", + "tui.permission.url_label": "URL: {{url}}", + "tui.permission.websearch_title": "Web Search \"{{query}}\"", + "tui.permission.codesearch_title": "Exa Code Search \"{{query}}\"", + "tui.permission.query_label": "Query: {{query}}", + "tui.permission.ext_dir_title": "Access external directory {{dir}}", + "tui.permission.patterns_label": "Patterns", + "tui.permission.doom_loop_title": "Continue after repeated failures", + "tui.permission.doom_loop_desc": "This keeps the session running despite repeated failures.", + "tui.permission.call_tool_title": "Call tool {{tool}}", + "tui.permission.tool_label": "Tool: {{tool}}", + + // Question dialog + "tui.question.confirm_tab": "Confirm", + "tui.question.select_all": " (select all that apply)", + "tui.question.custom_answer": "Type your own answer", + "tui.question.review_title": "Review", + "tui.question.not_answered": "(not answered)", + + // Prompt hint + "tui.prompt.hint.minimize": "minimize", + "tui.prompt.hint.fullscreen": "fullscreen", + "tui.prompt.hint.select": "select", + "tui.prompt.hint.confirm": "confirm", + "tui.prompt.hint.tab": "tab", + "tui.prompt.hint.submit": "submit", + "tui.prompt.hint.toggle": "toggle", + "tui.prompt.hint.dismiss": "dismiss", + + // MCP sidebar + "tui.mcp.label": "MCP", + "tui.mcp.connected": "Connected", + "tui.mcp.pending": "Pending approval", + "tui.mcp.disabled": "Disabled", + "tui.mcp.needs_auth": "Needs auth", + "tui.mcp.needs_client_id": "Needs client ID", + "tui.mcp.active_count": "active", + "tui.mcp.error_count": "error", + "tui.mcp.error_count_plural": "errors", + + // Status dialog + "tui.status.title": "Status", + "tui.status.no_mcp": "No MCP Servers", + "tui.status.mcp_count": "{{count}} MCP Servers", + "tui.status.connected": "Connected", + "tui.status.pending": "Pending approval", + "tui.status.disabled_config": "Disabled in configuration", + "tui.status.needs_auth": "Needs authentication (run: opencode mcp auth {{key}})", + "tui.status.no_lsp": "No LSP Servers", + "tui.status.lsp_count": "{{count}} LSP Servers", + "tui.status.no_formatters": "No Formatters", + "tui.status.formatter_count": "{{count}} Formatters", + "tui.status.no_plugins": "No Plugins", + "tui.status.plugin_count": "{{count}} Plugins", + + // Sidebar footer + "tui.sidebar.getting_started": "Getting started", + "tui.sidebar.free_models_desc": "MiMoCode includes free models so you can start immediately.", + "tui.sidebar.connect_providers_desc": "Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc", + "tui.sidebar.connect_provider": "Connect provider", + + // Error component + "tui.error.report_issue": "Please report an issue.", + "tui.error.copy_url": "Copy issue URL (exception info pre-filled)", + "tui.error.copied": "Successfully copied", + "tui.error.fatal": "A fatal error occurred!", + "tui.error.reset_tui": "Reset TUI", + "tui.error.exit": "Exit", + + // Skill dialog + "tui.dialog.skill.title": "Skills", + "tui.dialog.skill.placeholder": "Search skills...", + + // Provider dialog + "tui.dialog.provider.code.placeholder": "Authorization code", + "tui.dialog.provider.code.invalid": "Invalid code", + "tui.dialog.provider.api.placeholder": "API key", + "tui.dialog.provider.api.description.opencode": "OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key.", + "tui.dialog.provider.api.description.opencode-go": "OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models with generous usage limits.", + "tui.dialog.provider.auto.waiting": "Waiting for authorization...", + "tui.dialog.provider.auto.copy": "copy", + + + // Plugin UI + "tui.plugins.view.title": "Plugins", + "tui.plugins.view.toggle": "toggle", + "tui.plugins.view.install": "install", + "tui.plugins.install.title": "Install plugin", + "tui.plugins.install.placeholder": "npm package name", + "tui.plugins.install.busy": "Installing plugin...", + "tui.plugins.scope": "scope:", + "tui.plugins.scope_global": "global", + "tui.plugins.scope_local": "local", + "tui.plugins.toast.name_required": "Plugin package name is required", + "tui.plugins.toast.check_registry": "Check npm registry/auth settings and try again.", + "tui.plugins.toast.installed": "Installed {{mod}} ({{scope}}: {{dir}})", + "tui.plugins.toast.no_tui_target": "Package has no TUI target to load in this app.", + "tui.plugins.toast.load_failed": "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.", + "tui.plugins.toast.loaded": "Loaded {{mod}} in current session.", + "tui.plugins.category_internal": "Internal", + "tui.plugins.category_external": "External", + "tui.plugins.state.disabled": "disabled", + "tui.plugins.state.active": "active", + "tui.plugins.state.inactive": "inactive", + "tui.plugins.meta.builtin_long": "Built-in plugin", + "tui.plugins.meta.builtin": "Built-in", + "tui.plugins.scope.toggle": "({{key}} toggle)", + + // Prompt hint + "tui.prompt.hint.esc": "esc", + "tui.prompt.hint.interrupt": "interrupt", + "tui.prompt.hint.interrupt_again": "again to interrupt", + + // Message status + "tui.message.interrupted": "interrupted", + "tui.dialog.export.title": "Export Options", "tui.dialog.export.filename": "Filename:", "tui.dialog.export.filename_placeholder": "Enter filename", @@ -394,4 +546,52 @@ export const dict: Record = { // Session badges "tui.session.badge.auto": "Auto", + + // Retry display + "tui.prompt.retry.retrying": "retrying", + "tui.prompt.retry.inDuration": "in {{duration}}", + "tui.prompt.retry.attempt": "attempt #{{attempt}}", + + // Autocomplete + "tui.autocomplete.no_matching": "No matching items", + + // Shell mode + "tui.shell.exit": "exit shell mode", + + // Prompt + "tui.prompt.truncated.expand": "(click to expand)", + + // Reasoning / Thinking + "tui.reasoning.thinking": "Thinking", + "tui.reasoning.thinking_with_title": "Thinking: {{title}}", + "tui.reasoning.thought": "Thought", + "tui.reasoning.thought_with_title": "Thought: {{title}}", + + // Tool output + "tui.tool.click_to_expand": "Click to expand", + "tui.tool.click_to_collapse": "Click to collapse", + "tui.tool.click_to_expand_lines": "Click to expand ({{count}} {{word}})", + "tui.tool.click_to_expand_changes": "Click to expand ({{count}} change{{plural}})", + "tui.tool.line": "line", + "tui.tool.lines": "lines", + + // Errors & notifications + "tui.error.fork_failed": "Failed to fork session", + "tui.error.too_many_requests": "Too Many Requests", + "tui.error.provider_overloaded": "Provider is overloaded", + "tui.error.rate_limited": "Rate Limited", + "tui.error.transient_network": "Transient network error", + "tui.error.retry_title": "Retry Error", + "tui.heap_snapshot.written": "Heap snapshot written to {{files}}", + "tui.session.deleted": "The current session was deleted", + "tui.update.available.title": "Update Available", + "tui.update.available.message": "A new release v{{version}} is available. Would you like to update now?", + "tui.update.in_progress": "Updating to v{{version}}...", + "tui.update.failed.title": "Update Failed", + "tui.update.failed.message": "Update failed", + "tui.update.complete.title": "Update Complete", + "tui.update.complete.message": "Successfully updated to MiMoCode v{{version}}. Please restart the application.", + "tui.error.invalid_model": "Invalid model format: {{model}}", + "tui.error.interactive_reply_failed": "Interactive command reply failed: {{error}}", + "tui.bash.interactive": "(interactive)", } diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index bd7a1f54..1a998f67 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -430,4 +430,9 @@ export const dict = { // Session badges "tui.session.badge.auto": "Auto", + + // Retry display + "tui.prompt.retry.retrying": "reintentando", + "tui.prompt.retry.inDuration": "en {{duration}}", + "tui.prompt.retry.attempt": "intento #{{attempt}}", } satisfies Partial> diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index 5d75579f..fce92d54 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -437,4 +437,9 @@ export const dict = { // Session badges "tui.session.badge.auto": "Auto", + + // Retry display + "tui.prompt.retry.retrying": "nouvelle tentative", + "tui.prompt.retry.inDuration": "dans {{duration}}", + "tui.prompt.retry.attempt": "tentative #{{attempt}}", } satisfies Partial> diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index 72289f3e..3a4a0304 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -389,4 +389,9 @@ export const dict = { // Session badges "tui.session.badge.auto": "自動", + + // Retry display + "tui.prompt.retry.retrying": "再試行中", + "tui.prompt.retry.inDuration": "{{duration}}後", + "tui.prompt.retry.attempt": "第{{attempt}}回", } satisfies Partial> diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index 822731de..d2b0002d 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -449,4 +449,9 @@ export const dict = { // Session badges "tui.session.badge.auto": "Авто", + + // Retry display + "tui.prompt.retry.retrying": "повторная попытка", + "tui.prompt.retry.inDuration": "через {{duration}}", + "tui.prompt.retry.attempt": "попытка #{{attempt}}", } satisfies Partial> diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index 68b3f151..1c6c671f 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -237,6 +237,158 @@ export const dict = { "tui.dialog.prompt.processing": "处理中...", "tui.dialog.prompt.submit_key": "enter", "tui.dialog.prompt.submit_action": "提交", + // Dialog message actions + "tui.dialog.message.title": "消息操作", + "tui.dialog.message.revert.title": "撤销", + "tui.dialog.message.revert.description": "撤销消息和文件更改", + "tui.dialog.message.copy.title": "复制", + "tui.dialog.message.copy.description": "将消息文本复制到剪贴板", + "tui.dialog.message.fork.title": "派生", + "tui.dialog.message.fork.description": "创建新会话", + + // Question prompt + "tui.question.answer.placeholder": "输入你的答案", + + // Permission dialog + "tui.permission.title": "需要权限", + "tui.permission.allow_once": "允许一次", + "tui.permission.allow_always": "始终允许", + "tui.permission.reject": "拒绝", + "tui.permission.reject.title": "拒绝权限", + "tui.permission.reject.placeholder": "告诉 MiMoCode 该怎么做", + "tui.permission.reject.confirm": "确认", + "tui.permission.reject.cancel": "取消", + "tui.permission.allow_always.title_single": "将允许 {{permission}},直到 MiMoCode 重启。", + "tui.permission.allow_always.title_multi": "将允许以下模式,直到 MiMoCode 重启", + "tui.permission.allow_always.confirm": "确认", + "tui.permission.allow_always.cancel": "取消", + "tui.permission.no_diff": "无差异内容", + "tui.permission.edit_title": "编辑 {{path}}", + "tui.permission.read_title": "读取 {{path}}", + "tui.permission.path_label": "路径:{{path}}", + "tui.permission.glob_title": "Glob \"{{pattern}}\"", + "tui.permission.grep_title": "Grep \"{{pattern}}\"", + "tui.permission.pattern_label": "模式:{{pattern}}", + "tui.permission.list_title": "列出 {{path}}", + "tui.permission.shell_command": "Shell 命令", + "tui.permission.unknown": "未知", + "tui.permission.task_title": "{{type}} 任务", + "tui.permission.webfetch_title": "WebFetch {{url}}", + "tui.permission.url_label": "URL:{{url}}", + "tui.permission.websearch_title": "Web 搜索 \"{{query}}\"", + "tui.permission.codesearch_title": "Exa 代码搜索 \"{{query}}\"", + "tui.permission.query_label": "查询:{{query}}", + "tui.permission.ext_dir_title": "访问外部目录 {{dir}}", + "tui.permission.patterns_label": "模式", + "tui.permission.doom_loop_title": "在反复失败后继续", + "tui.permission.doom_loop_desc": "让会话在反复失败的情况下继续运行。", + "tui.permission.call_tool_title": "调用工具 {{tool}}", + "tui.permission.tool_label": "工具:{{tool}}", + + // Question dialog + "tui.question.confirm_tab": "确认", + "tui.question.select_all": "(可多选)", + "tui.question.custom_answer": "输入自定义答案", + "tui.question.review_title": "回顾", + "tui.question.not_answered": "(未回答)", + + // Prompt hint + "tui.prompt.hint.minimize": "最小化", + "tui.prompt.hint.fullscreen": "全屏", + "tui.prompt.hint.select": "选择", + "tui.prompt.hint.confirm": "确认", + "tui.prompt.hint.tab": "切换", + "tui.prompt.hint.submit": "提交", + "tui.prompt.hint.toggle": "切换", + "tui.prompt.hint.dismiss": "取消", + + // MCP sidebar + "tui.mcp.label": "MCP", + "tui.mcp.connected": "已连接", + "tui.mcp.pending": "待批准", + "tui.mcp.disabled": "已禁用", + "tui.mcp.needs_auth": "需要授权", + "tui.mcp.needs_client_id": "需要客户端 ID", + "tui.mcp.active_count": "活跃", + "tui.mcp.error_count": "错误", + "tui.mcp.error_count_plural": "个错误", + + // Status dialog + "tui.status.title": "状态", + "tui.status.no_mcp": "无 MCP 服务器", + "tui.status.mcp_count": "{{count}} 个 MCP 服务器", + "tui.status.connected": "已连接", + "tui.status.pending": "待批准", + "tui.status.disabled_config": "配置中已禁用", + "tui.status.needs_auth": "需要认证(运行:opencode mcp auth {{key}})", + "tui.status.no_lsp": "无 LSP 服务器", + "tui.status.lsp_count": "{{count}} 个 LSP 服务器", + "tui.status.no_formatters": "无格式化器", + "tui.status.formatter_count": "{{count}} 个格式化器", + "tui.status.no_plugins": "无插件", + "tui.status.plugin_count": "{{count}} 个插件", + + // Sidebar footer + "tui.sidebar.getting_started": "开始使用", + "tui.sidebar.free_models_desc": "MiMoCode 包含免费模型,你可以立即开始使用。", + "tui.sidebar.connect_providers_desc": "连接 75+ 服务商以使用其他模型,包括 Claude、GPT、Gemini 等", + "tui.sidebar.connect_provider": "连接服务商", + + // Error component + "tui.error.report_issue": "请报告问题。", + "tui.error.copy_url": "复制问题链接(已预填异常信息)", + "tui.error.copied": "已复制", + "tui.error.fatal": "发生致命错误!", + "tui.error.reset_tui": "重置 TUI", + "tui.error.exit": "退出", + + // Skill dialog + "tui.dialog.skill.title": "技能", + "tui.dialog.skill.placeholder": "搜索技能...", + + // Provider dialog + "tui.dialog.provider.code.placeholder": "授权码", + "tui.dialog.provider.code.invalid": "无效的授权码", + "tui.dialog.provider.api.placeholder": "API 密钥", + "tui.dialog.provider.api.description.opencode": "OpenCode Zen 让你以最优惠的价格通过单个 API 密钥访问所有最佳编程模型。", + "tui.dialog.provider.api.description.opencode-go": "OpenCode Go 是一项每月 10 美元的订阅服务,为你提供对热门开源编码模型的可靠访问和慷慨的使用额度。", + "tui.dialog.provider.auto.waiting": "等待授权中...", + "tui.dialog.provider.auto.copy": "复制", + + + // Plugin UI + "tui.plugins.view.title": "插件", + "tui.plugins.view.toggle": "切换", + "tui.plugins.view.install": "安装", + "tui.plugins.install.title": "安装插件", + "tui.plugins.install.placeholder": "npm 包名", + "tui.plugins.install.busy": "正在安装插件...", + "tui.plugins.scope": "范围:", + "tui.plugins.scope_global": "全局", + "tui.plugins.scope_local": "本地", + "tui.plugins.toast.name_required": "需要提供插件包名", + "tui.plugins.toast.check_registry": "检查 npm 仓库/认证设置后重试。", + "tui.plugins.toast.installed": "已安装 {{mod}}({{scope}}:{{dir}})", + "tui.plugins.toast.no_tui_target": "该包在此应用中没有可加载的 TUI 目标。", + "tui.plugins.toast.load_failed": "已安装插件,但运行时加载失败。查看控制台/日志;重启 TUI 重试。", + "tui.plugins.toast.loaded": "已在当前会话中加载 {{mod}}。", + "tui.plugins.category_internal": "内置", + "tui.plugins.category_external": "外部", + "tui.plugins.state.disabled": "已禁用", + "tui.plugins.state.active": "运行中", + "tui.plugins.state.inactive": "未运行", + "tui.plugins.meta.builtin_long": "内置插件", + "tui.plugins.meta.builtin": "内置", + "tui.plugins.scope.toggle": "({{key}} 切换)", + + // Prompt hint + "tui.prompt.hint.esc": "esc", + "tui.prompt.hint.interrupt": "中断", + "tui.prompt.hint.interrupt_again": "再次按 esc 中断", + + // Message status + "tui.message.interrupted": "已中断", + "tui.dialog.export.title": "导出选项", "tui.dialog.export.filename": "文件名:", "tui.dialog.export.filename_placeholder": "输入文件名", @@ -387,4 +539,52 @@ export const dict = { // Session badges "tui.session.badge.auto": "自动", + + // Retry display + "tui.prompt.retry.retrying": "正在重试", + "tui.prompt.retry.inDuration": "{{duration}}后", + "tui.prompt.retry.attempt": "第{{attempt}}次", + + // Autocomplete + "tui.autocomplete.no_matching": "无匹配项", + + // Shell mode + "tui.shell.exit": "退出 Shell 模式", + + // Prompt + "tui.prompt.truncated.expand": "(点击展开)", + + // Reasoning / Thinking + "tui.reasoning.thinking": "思考中", + "tui.reasoning.thinking_with_title": "思考中:{{title}}", + "tui.reasoning.thought": "思考", + "tui.reasoning.thought_with_title": "思考:{{title}}", + + // Tool output + "tui.tool.click_to_expand": "点击展开", + "tui.tool.click_to_collapse": "点击折叠", + "tui.tool.click_to_expand_lines": "点击展开({{count}} {{word}})", + "tui.tool.click_to_expand_changes": "点击展开({{count}} 项更改)", + "tui.tool.line": "行", + "tui.tool.lines": "行", + + // Errors & notifications + "tui.error.fork_failed": "派生会话失败", + "tui.error.too_many_requests": "请求过多", + "tui.error.provider_overloaded": "服务商负载过高", + "tui.error.rate_limited": "请求频率受限", + "tui.error.transient_network": "临时网络错误", + "tui.error.retry_title": "重试错误", + "tui.heap_snapshot.written": "堆快照已写入 {{files}}", + "tui.session.deleted": "当前会话已删除", + "tui.update.available.title": "有可用更新", + "tui.update.available.message": "新版本 v{{version}} 已可用,是否立即更新?", + "tui.update.in_progress": "正在更新到 v{{version}}...", + "tui.update.failed.title": "更新失败", + "tui.update.failed.message": "更新失败", + "tui.update.complete.title": "更新完成", + "tui.update.complete.message": "已成功更新到 MiMoCode v{{version}},请重新启动应用。", + "tui.error.invalid_model": "模型格式无效:{{model}}", + "tui.error.interactive_reply_failed": "交互命令回复失败:{{error}}", + "tui.bash.interactive": "(交互式)", } satisfies Partial> diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 0535d88f..e79ab824 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -347,6 +347,38 @@ export const dict = { "tui.command.plugins.list.title": "外掛", "tui.command.plugins.install.title": "安裝外掛", + // Provider login dialog + "tui.dialog.login.title": "選擇服務商", + "tui.dialog.login.xiaomi": "小米", + "tui.dialog.login.xiaomi.desc": "(推薦)", + "tui.dialog.login.mimo_free": "MiMo Auto (free)", + "tui.dialog.login.mimo_free.desc": "免費匿名通道,無需登入", + "tui.dialog.login.mimo_free.success": "MiMo Auto (free) 已就緒 - 預設模型設為 mimo/mimo-auto", + "tui.dialog.login.mimo_free.unavailable": "MiMo Auto (free) 通道未載入", + "cli.providers.select": "選擇服務商", + "cli.providers.other": "其他 Provider", + "cli.providers.mimo.recommended_hint": "推薦", + "cli.providers.mimo_free.hint": "免費匿名通道 / mimo-auto", + "cli.providers.mimo_free.verifying": "正在驗證 MiMo Auto (free) 通道...", + "cli.providers.mimo_free.ready": "MiMo Auto (free) 通道已就緒", + "cli.providers.mimo_free.failed": "MiMo Auto (free) 自檢失敗", + "cli.providers.mimo_free.default_set": "預設模型已切換為 mimo/mimo-auto(1M 上下文,免費)", + "cli.providers.mimo_free.usage_hint": "無需登入,直接 mimo run 即可使用。如需付費/更高階模型,可重新選擇 MiMo 瀏覽器登入。", + "cli.providers.mimo_login.decrypt_retry": "解密失敗,請重試(剩餘 {remaining} 次)", + "cli.providers.mimo_login.decrypt_exhausted": "解密失敗,已達最大重試次數", + "tui.dialog.login.import_claude": "從 Claude Code 匯入", + "tui.dialog.login.other": "其他服務商", + "tui.dialog.login.import_claude.no_key": "找不到 Claude Code API Key", + "tui.dialog.login.import_claude.read_failed": "讀取 ~/.claude/settings.json 失敗", + "tui.dialog.login.import_claude.success": "已從 Claude Code 匯入設定", + "tui.dialog.login.start_failed": "啟動登入失敗", + "tui.dialog.login.flow.title": "MiMo 登入", + "tui.dialog.login.flow.placeholder": "貼上 Code(或等待瀏覽器回呼)", + "tui.dialog.login.flow.busy": "登入中...", + "tui.dialog.login.flow.manual_hint": "瀏覽器未開啟?手動前往:", + "tui.dialog.login.flow.waiting": "等待瀏覽器授權中...", + "tui.dialog.login.flow.invalid_code": "Code 無效,請重試", + // Question i18n — plan_exit "tui.question.plan_exit.question": "{{plan}} 的計劃已完成。是否切換到 build 智慧代理開始實作?", "tui.question.plan_exit.header": "退出計劃", @@ -357,4 +389,105 @@ export const dict = { // Session badges "tui.session.badge.auto": "自動", + + // Retry display + "tui.prompt.retry.retrying": "正在重試", + "tui.prompt.retry.inDuration": "{{duration}}後", + "tui.prompt.retry.attempt": "第{{attempt}}次", + + // Autocomplete + "tui.autocomplete.no_matching": "無符合項目", + + // Shell mode + "tui.shell.exit": "退出 Shell 模式", + + // Prompt + "tui.prompt.truncated.expand": "(點選展開)", + + // Errors & notifications + "tui.error.fork_failed": "分叉工作階段失敗", + "tui.error.too_many_requests": "請求過多", + "tui.error.provider_overloaded": "服務商負載過高", + "tui.error.rate_limited": "請求頻率受限", + "tui.error.transient_network": "臨時網路錯誤", + "tui.error.retry_title": "重試錯誤", + "tui.heap_snapshot.written": "堆積快照已寫入 {{files}}", + "tui.session.deleted": "目前工作階段已刪除", + "tui.update.available.title": "有可用更新", + "tui.update.available.message": "新版本 v{{version}} 已可用,是否立即更新?", + "tui.update.in_progress": "正在更新到 v{{version}}...", + "tui.update.failed.title": "更新失敗", + "tui.update.failed.message": "更新失敗", + "tui.update.complete.title": "更新完成", + "tui.update.complete.message": "已成功更新到 MiMoCode v{{version}},請重新啟動應用程式。", + "tui.error.invalid_model": "模型格式無效:{{model}}", + "tui.error.interactive_reply_failed": "互動命令回覆失敗:{{error}}", + "tui.bash.interactive": "(互動式)", + + // Permission dialog + "tui.permission.title": "需要權限", + "tui.permission.allow_once": "允許一次", + "tui.permission.allow_always": "始終允許", + "tui.permission.reject": "拒絕", + "tui.permission.reject.title": "拒絕權限", + "tui.permission.reject.placeholder": "告訴 MiMoCode 該怎麼做", + "tui.permission.reject.confirm": "確認", + "tui.permission.reject.cancel": "取消", + "tui.permission.allow_always.title_single": "將允許 {{permission}},直到 MiMoCode 重啟。", + "tui.permission.allow_always.title_multi": "將允許以下模式,直到 MiMoCode 重啟", + "tui.permission.allow_always.confirm": "確認", + "tui.permission.allow_always.cancel": "取消", + "tui.permission.no_diff": "無差異內容", + "tui.permission.edit_title": "編輯 {{path}}", + "tui.permission.read_title": "讀取 {{path}}", + "tui.permission.path_label": "路徑:{{path}}", + "tui.permission.glob_title": "Glob \"{{pattern}}\"", + "tui.permission.grep_title": "Grep \"{{pattern}}\"", + "tui.permission.pattern_label": "模式:{{pattern}}", + "tui.permission.list_title": "列出 {{path}}", + "tui.permission.shell_command": "Shell 命令", + "tui.permission.unknown": "未知", + "tui.permission.task_title": "{{type}} 任務", + "tui.permission.webfetch_title": "WebFetch {{url}}", + "tui.permission.url_label": "URL:{{url}}", + "tui.permission.websearch_title": "Web 搜尋 \"{{query}}\"", + "tui.permission.codesearch_title": "Exa 程式碼搜尋 \"{{query}}\"", + "tui.permission.query_label": "查詢:{{query}}", + "tui.permission.ext_dir_title": "存取外部目錄 {{dir}}", + "tui.permission.patterns_label": "模式", + "tui.permission.doom_loop_title": "在反覆失敗後繼續", + "tui.permission.doom_loop_desc": "讓工作階段在反覆失敗的情況下繼續運行。", + "tui.permission.call_tool_title": "呼叫工具 {{tool}}", + "tui.permission.tool_label": "工具:{{tool}}", + + // Question dialog + "tui.question.confirm_tab": "確認", + "tui.question.select_all": "(可多選)", + "tui.question.custom_answer": "輸入自訂答案", + "tui.question.review_title": "回顧", + "tui.question.not_answered": "(未回答)", + + // Prompt hint + "tui.prompt.hint.minimize": "最小化", + "tui.prompt.hint.fullscreen": "全螢幕", + "tui.prompt.hint.select": "選擇", + "tui.prompt.hint.confirm": "確認", + "tui.prompt.hint.tab": "切換", + "tui.prompt.hint.submit": "提交", + "tui.prompt.hint.toggle": "切換", + "tui.prompt.hint.dismiss": "取消", + + // Reasoning / Thinking + "tui.reasoning.thinking": "思考中", + "tui.reasoning.thinking_with_title": "思考中:{{title}}", + "tui.reasoning.thought": "思考", + "tui.reasoning.thought_with_title": "思考:{{title}}", + + // Tool output + "tui.tool.click_to_expand": "點擊展開", + "tui.tool.click_to_collapse": "點擊折疊", + "tui.tool.click_to_expand_lines": "點擊展開({{count}} {{word}})", + "tui.tool.click_to_expand_changes": "點擊展開({{count}} 項更改)", + "tui.tool.line": "行", + "tui.tool.lines": "行", } satisfies Partial> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index e7d3f3a5..9fa7b495 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -6,6 +6,7 @@ import { useRoute } from "@tui/context/route" import * as Clipboard from "@tui/util/clipboard" import type { PromptInfo } from "@tui/component/prompt/history" import { strip } from "@tui/component/prompt/part" +import { useLanguage } from "@tui/context/language" export function DialogMessage(props: { messageID: string @@ -24,15 +25,16 @@ export function DialogMessage(props: { return undefined }) const route = useRoute() + const t = useLanguage().t return ( { const msg = message() if (!msg) return @@ -61,9 +63,9 @@ export function DialogMessage(props: { }, }, { - title: "Copy", + title: t("tui.dialog.message.copy.title"), value: "message.copy", - description: "message text to clipboard", + description: t("tui.dialog.message.copy.description"), onSelect: async (dialog) => { const msg = message() if (!msg) return @@ -81,9 +83,9 @@ export function DialogMessage(props: { }, }, { - title: "Fork", + title: t("tui.dialog.message.fork.title"), value: "session.fork", - description: "create a new session", + description: t("tui.dialog.message.fork.description"), onSelect: async (dialog) => { const result = await sdk.client.session.fork({ sessionID: props.sessionID, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index c2a6f525..2da623a4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1339,6 +1339,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const ctx = use() const local = useLocal() const { theme } = useTheme() + const t = useLanguage().t const sync = useSync() const messages = createMemo(() => sync.data.message[props.message.sessionID]?.[props.message.agentID ?? "main"] ?? []) const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) @@ -1419,7 +1420,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} - · interrupted + · {t("tui.message.interrupted")} @@ -1552,6 +1553,7 @@ function ReasoningHeader(props: { duration?: string }) { const { theme } = useTheme() + const t = useLanguage().t const fg = () => props.open ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) @@ -1561,7 +1563,9 @@ function ReasoningHeader(props: { - {props.title ? "Thinking: " + props.title : "Thinking"} + + {props.title ? t("tui.reasoning.thinking_with_title", { title: props.title }) : t("tui.reasoning.thinking")} + @@ -1569,16 +1573,15 @@ function ReasoningHeader(props: { {props.open ? "- " : "+ "} - Thought - : + {t("tui.reasoning.thought_with_title", { title: props.title ?? "" })} - - {props.title} + + {t("tui.reasoning.thought")} - {props.title ? " · " : ""} + {" · "} {props.duration} @@ -1745,6 +1748,7 @@ function PlanExit(props: ToolProps) { function GenericTool(props: ToolProps) { const { theme } = useTheme() + const t = useLanguage().t const ctx = use() const output = createMemo(() => props.output?.trim() ?? "") const [expanded, setExpanded] = createSignal(false) @@ -1773,7 +1777,7 @@ function GenericTool(props: ToolProps) { {limited()} - {expanded() ? "Click to collapse" : "Click to expand"} + {expanded() ? t("tui.tool.click_to_collapse") : t("tui.tool.click_to_expand")} @@ -1990,6 +1994,7 @@ function hasLongDisplayLine(content: string) { function Bash(props: ToolProps) { const { theme } = useTheme() + const t = useLanguage().t const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) @@ -2041,7 +2046,7 @@ function Bash(props: ToolProps) { {limited()} - {expanded() ? "Click to collapse" : "Click to expand"} + {expanded() ? t("tui.tool.click_to_collapse") : t("tui.tool.click_to_expand")} @@ -2057,6 +2062,7 @@ function Bash(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() + const t = useLanguage().t const [expanded, setExpanded] = createSignal(false) const code = createMemo(() => { if (!props.input.content) return "" @@ -2077,7 +2083,7 @@ function Write(props: ToolProps) { when={!collapsed() || expanded()} fallback={ - Click to expand ({lineCount()} {lineCount() === 1 ? "line" : "lines"}) + {t("tui.tool.click_to_expand_lines", { count: lineCount(), word: lineCount() === 1 ? t("tui.tool.line") : t("tui.tool.lines") })} } > @@ -2091,7 +2097,7 @@ function Write(props: ToolProps) { /> - Click to collapse + {t("tui.tool.click_to_collapse")} @@ -2336,6 +2342,7 @@ function Edit(props: ToolProps) { function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const t = useLanguage().t const [expanded, setExpanded] = createSignal([]) const files = createMemo(() => props.metadata.files ?? []) @@ -2402,7 +2409,7 @@ function ApplyPatch(props: ToolProps) { when={file.type !== "delete"} fallback={ - -{file.deletions} line{file.deletions !== 1 ? "s" : ""} + -{file.deletions} {file.deletions === 1 ? t("tui.tool.line") : t("tui.tool.lines")} } > @@ -2410,13 +2417,13 @@ function ApplyPatch(props: ToolProps) { when={!collapsed() || open()} fallback={ - Click to expand ({count()} change{count() !== 1 ? "s" : ""}) + {t("tui.tool.click_to_expand_changes", { count: count(), plural: count() !== 1 ? "s" : "" })} } > - Click to collapse + {t("tui.tool.click_to_collapse")} @@ -2428,7 +2435,7 @@ function ApplyPatch(props: ToolProps) { - Patch + {"Patch"} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 39b48a3e..1f12f164 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -17,6 +17,7 @@ import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" +import { useLanguage } from "@tui/context/language" type PermissionStage = "permission" | "always" | "reject" @@ -52,6 +53,7 @@ function EditBody(props: { request: PermissionRequest }) { const syntax = themeState.syntax const config = useTuiConfig() const dimensions = useTerminalDimensions() + const t = useLanguage().t const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "") const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "") @@ -101,7 +103,7 @@ function EditBody(props: { request: PermissionRequest }) { - No diff provided + {t("tui.permission.no_diff")} @@ -151,20 +153,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { }) const { theme } = useTheme() + const t = useLanguage().t return ( - + - This will allow the following patterns until MiMoCode is restarted + {t("tui.permission.allow_always.title_multi")} {(pattern) => ( @@ -179,7 +182,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } - options={{ confirm: "Confirm", cancel: "Cancel" }} + options={{ confirm: t("tui.permission.allow_always.confirm"), cancel: t("tui.permission.allow_always.cancel") }} escapeKey="cancel" onSelect={(option) => { setStore("stage", "permission") @@ -216,7 +219,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filepath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Edit ${normalizePath(filepath)}`, + title: t("tui.permission.edit_title", { path: normalizePath(filepath) }), body: , } } @@ -226,11 +229,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filePath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Read ${normalizePath(filePath)}`, + title: t("tui.permission.read_title", { path: normalizePath(filePath) }), body: ( - {"Path: " + normalizePath(filePath)} + {t("tui.permission.path_label", { path: normalizePath(filePath) })} ), @@ -241,11 +244,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const pattern = typeof data.pattern === "string" ? data.pattern : "" return { icon: "✱", - title: `Glob "${pattern}"`, + title: t("tui.permission.glob_title", { pattern }), body: ( - {"Pattern: " + pattern} + {t("tui.permission.pattern_label", { pattern })} ), @@ -256,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const pattern = typeof data.pattern === "string" ? data.pattern : "" return { icon: "✱", - title: `Grep "${pattern}"`, + title: t("tui.permission.grep_title", { pattern }), body: ( - {"Pattern: " + pattern} + {t("tui.permission.pattern_label", { pattern })} ), @@ -272,11 +275,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const dir = typeof raw === "string" ? raw : "" return { icon: "→", - title: `List ${normalizePath(dir)}`, + title: t("tui.permission.list_title", { path: normalizePath(dir) }), body: ( - {"Path: " + normalizePath(dir)} + {t("tui.permission.path_label", { path: normalizePath(dir) })} ), @@ -285,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { if (permission === "bash") { const title = - typeof data.description === "string" && data.description ? data.description : "Shell command" + typeof data.description === "string" && data.description ? data.description : t("tui.permission.shell_command") const command = typeof data.command === "string" ? data.command : "" return { icon: "#", @@ -301,11 +304,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } if (permission === "task") { - const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" + const type = typeof data.subagent_type === "string" ? data.subagent_type : t("tui.permission.unknown") const desc = typeof data.description === "string" ? data.description : "" return { icon: "#", - title: `${Locale.titlecase(type)} Task`, + title: t("tui.permission.task_title", { type: Locale.titlecase(type) }), body: ( @@ -320,11 +323,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const url = typeof data.url === "string" ? data.url : "" return { icon: "%", - title: `WebFetch ${url}`, + title: t("tui.permission.webfetch_title", { url }), body: ( - {"URL: " + url} + {t("tui.permission.url_label", { url })} ), @@ -335,11 +338,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const query = typeof data.query === "string" ? data.query : "" return { icon: "◈", - title: `Web Search "${query}"`, + title: t("tui.permission.websearch_title", { query }), body: ( - {"Query: " + query} + {t("tui.permission.query_label", { query })} ), @@ -350,11 +353,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const query = typeof data.query === "string" ? data.query : "" return { icon: "◇", - title: `Exa Code Search "${query}"`, + title: t("tui.permission.codesearch_title", { query }), body: ( - {"Query: " + query} + {t("tui.permission.query_label", { query })} ), @@ -375,11 +378,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { return { icon: "←", - title: `Access external directory ${dir}`, + title: t("tui.permission.ext_dir_title", { dir }), body: ( 0}> - Patterns + {t("tui.permission.patterns_label")} {(p) => {"- " + p}} @@ -392,10 +395,10 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { if (permission === "doom_loop") { return { icon: "⟳", - title: "Continue after repeated failures", + title: t("tui.permission.doom_loop_title"), body: ( - This keeps the session running despite repeated failures. + {t("tui.permission.doom_loop_desc")} ), } @@ -403,10 +406,10 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { return { icon: "⚙", - title: `Call tool ${permission}`, + title: t("tui.permission.call_tool_title", { tool: permission }), body: ( - {"Tool: " + permission} + {t("tui.permission.tool_label", { tool: permission })} ), } @@ -418,7 +421,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { {"△"} - Permission required + {t("tui.permission.title")} @@ -431,10 +434,10 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const body = ( { @@ -471,6 +474,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable const { theme } = useTheme() + const t = useLanguage().t const keybind = useKeybind() const textareaKeybindings = useTextareaKeybindings() const dimensions = useTerminalDimensions() @@ -501,10 +505,10 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( {"△"} - Reject permission + {t("tui.permission.reject.title")} - Tell MiMoCode what to do differently + {t("tui.permission.reject.placeholder")} void; onCancel: ( /> - enter confirm + enter {t("tui.permission.reject.confirm")} - esc cancel + {t("tui.prompt.hint.esc")} {t("tui.permission.reject.cancel")} @@ -553,6 +557,7 @@ function Prompt>(props: { onSelect: (option: keyof T) => void }) { const { theme } = useTheme() + const t = useLanguage().t const keybind = useKeybind() const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] @@ -598,7 +603,7 @@ function Prompt>(props: { } }) - const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) + const hint = createMemo(() => (store.expanded ? t("tui.prompt.hint.minimize") : t("tui.prompt.hint.fullscreen"))) useRenderer() const content = () => ( @@ -673,10 +678,10 @@ function Prompt>(props: { - {"⇆"} select + {"⇆"} {t("tui.prompt.hint.select")} - enter confirm + enter {t("tui.prompt.hint.confirm")} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 249c27c4..3a4abd5c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -325,7 +325,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { onMouseOut={() => setTabHover(null)} onMouseUp={() => selectTab(questions().length)} > - Confirm + {t("tui.question.confirm_tab")} @@ -335,7 +335,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { {question()?.question} - {multi() ? " (select all that apply)" : ""} + {multi() ? t("tui.question.select_all") : ""} @@ -387,7 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"} + {multi() ? `[${customPicked() ? "✓" : " "}] ${t("tui.question.custom_answer")}` : t("tui.question.custom_answer")} @@ -407,7 +407,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { }) }} initialValue={input()} - placeholder="Type your own answer" + placeholder={t("tui.question.answer.placeholder")} placeholderColor={theme.textMuted} minHeight={1} maxHeight={6} @@ -431,7 +431,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - Review + {t("tui.question.review_title")} {(q, index) => { @@ -442,7 +442,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { {q.header}:{" "} - {answered() ? value() : "(not answered)"} + {answered() ? value() : t("tui.question.not_answered")} @@ -463,23 +463,23 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - {"⇆"} tab + {"⇆"} {t("tui.prompt.hint.tab")} - {"↑↓"} select + {"↑↓"} {t("tui.prompt.hint.select")} enter{" "} - {confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"} + {confirm() ? t("tui.prompt.hint.submit") : multi() ? t("tui.prompt.hint.toggle") : single() ? t("tui.prompt.hint.submit") : t("tui.prompt.hint.confirm")} - esc dismiss + esc {t("tui.prompt.hint.dismiss")} diff --git a/packages/opencode/src/command/template/initialize.txt b/packages/opencode/src/command/template/initialize.txt index 90751e3d..a272820f 100644 --- a/packages/opencode/src/command/template/initialize.txt +++ b/packages/opencode/src/command/template/initialize.txt @@ -1,6 +1,28 @@ -Create or update `AGENTS.md` for this repository. +> 语言配置:`zh.ts` — 全程中文模式 -The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out. +Create or update `AGENTS.md` for this repository. **All output must be in Simplified Chinese (简体中文).** + +The goal is a compact instruction file that helps future sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out. + +When creating/updating AGENTS.md, you MUST include the following **language enforcement section** at the top: + +```markdown +## 0. 语言强制约束(/init 激活,永久生效) +1. **所有自然语言输出必须使用简体中文**。包括但不限于:需求分析、方案描述、代码解释、报错分析、总结汇报、对话问答。 +2. **允许保留英文的例外**:编程语言关键字、函数名/类名、开源项目原名、通用专业缩写(API/SDK/CLI/CI等)、代码内标识符、技术术语。 +3. **禁止**:整段英文回复、英文总结、默认英文解释需求。 +4. **代码注释优先中文**,长说明必须中文。 +5. **报错提示、指引文案**统一中文展示。 +``` + +Also include a **command reference section**: + +```markdown +## 0.2 命令说明 +- **`/init`**:重载 AGENTS.md 全部规则,绑定 zh.ts 中文语言配置,重置会话上下文约束。 +- **`/review`**:审查未提交更改、commit、branch 或 PR,从近期工作中提取重复工作流封装为可复用 skills。 +- **`/distill`**:设定明确停止条件目标,持续运行直到评估机制判定目标达成,达成后自动清理目标(goal clear)。 +``` User-provided focus or constraints (honor these): $ARGUMENTS @@ -12,7 +34,7 @@ Read the highest-value sources first: - build, test, lint, formatter, typecheck, and codegen config - CI workflows and pre-commit / task runner config - existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`) -- repo-local OpenCode config such as `opencode.json` +- repo-local config such as `opencode.json` or `.mimocode/` If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files. @@ -57,7 +79,7 @@ Exclude: - long tutorials or exhaustive file trees - obvious language conventions - speculative claims or anything you could not verify -- content better stored in another file referenced via `opencode.json` `instructions` +- content better stored in another file referenced via config instructions When in doubt, omit. diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index b745247e..98978633 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -1,4 +1,6 @@ -You are a code reviewer. Your job is to review code changes and provide actionable feedback. +> 语言配置:`zh.ts` — 全程中文模式 + +You are a code reviewer. **All output must be in Simplified Chinese (简体中文).** Your job is to review code changes and provide actionable feedback. --- @@ -61,6 +63,20 @@ Use best judgement when processing input. --- +## Extract Repeated Workflows As Skills + +In addition to reviewing the changes, identify any **repeated manual workflows** in the diff: + +1. Look for patterns that appear multiple times (similar file edits, similar command patterns, repeated code blocks) +2. Check if these patterns already have corresponding skills in `.mimocode/skills/` +3. If a clear repeated workflow is found WITHOUT an existing skill, create a skill: + - Write `.mimocode/skills//SKILL.md` with YAML frontmatter (`name`, `description`) + - Include a focused, imperative description that makes it discoverable +4. Only create skills for workflows that: + - Appeared at least twice in the changes + - Have stable inputs, repeatable procedure, clear output + - Would materially improve speed or consistency + ## Before You Flag Something **Be certain.** If you're going to call something a bug, you need to be confident it actually is one. @@ -99,3 +115,4 @@ If you're uncertain about something and can't verify it with these tools, say "I 4. Your tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer. 5. Write so the reader can quickly understand the issue without reading too closely. 6. AVOID flattery, do not give any comments that are not helpful to the reader. Avoid phrasing like "Great job ...", "Thanks for ...". +7. **Include a "Skills" section** at the end if any new skills were created or if existing skills could cover identified patterns. diff --git a/packages/opencode/src/plugin/mimo-free.ts b/packages/opencode/src/plugin/mimo-free.ts index d503ea31..22e5eb75 100644 --- a/packages/opencode/src/plugin/mimo-free.ts +++ b/packages/opencode/src/plugin/mimo-free.ts @@ -123,26 +123,24 @@ export async function MimoFreeAuthPlugin(_input: PluginInput): Promise { return { config: async (input) => { input.provider ??= {} - input.provider.mimo ??= { - name: "MiMo Auto (free)", - npm: "@ai-sdk/openai-compatible", - api: CHAT_BASE_URL, - options: { - apiKey: "anonymous", - fetch: wrappedFetch, - }, - models: { - "mimo-auto": { - name: "MiMo Auto", - attachment: true, - reasoning: true, - tool_call: true, - temperature: true, - modalities: { input: ["text", "image"], output: ["text"] }, - limit: { context: 1_000_000, output: 128_000 }, - cost: { input: 0, output: 0 }, - }, - }, + input.provider.mimo ??= {} + const mimo = input.provider.mimo + mimo.name ??= "MiMo Auto (free)" + mimo.npm ??= "@ai-sdk/openai-compatible" + mimo.api ??= CHAT_BASE_URL + mimo.options ??= {} + mimo.options.apiKey ??= "anonymous" + mimo.options.fetch ??= wrappedFetch + mimo.models ??= {} + mimo.models["mimo-auto"] ??= { + name: "MiMo Auto", + attachment: true, + reasoning: true, + tool_call: true, + temperature: true, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1_000_000, output: 128_000 }, + cost: { input: 0, output: 0 }, } input.disabled_providers ??= [] for (const id of ["opencode", "opencode-go"]) { diff --git a/packages/opencode/src/session/classify.ts b/packages/opencode/src/session/classify.ts index ed13936e..f2af909c 100644 --- a/packages/opencode/src/session/classify.ts +++ b/packages/opencode/src/session/classify.ts @@ -35,8 +35,17 @@ export function classifyAssistantStep(input: { phase: "existing-assistant" | "after-process" // Reserved for T01–T05 (stop/overflow control flow stays in runLoop for T00). processResult?: "continue" | "stop" | "overflow" + /** + * Milliseconds after which a tool part still in `running` state is considered + * stale (abandoned by the tool executor). Stale running parts are treated as + * if they were in `error` state — they do NOT trigger the `continue` path. + * Default: 5 minutes (300_000 ms). Set to 0 or Infinity to disable. + */ + staleToolCallMs?: number }): StepClassification { const assistant = input.assistant + const staleMs = input.staleToolCallMs ?? 300_000 + const now = Date.now() // 1. Core guarantee — beats everything: a pending client tool call must // re-loop so its observation is fed back to the model. EXCLUDE error-state @@ -45,12 +54,20 @@ export function classifyAssistantStep(input: { // terminal failures. Without this guard, classify mis-routes errored steps // to "continue", runLoop re-enters and gets stranded on permission.ask // from the in-flight tool that won't ever resolve. See Spec ③. + // + // P0 fix: also EXCLUDE stale `running` tool parts — ones where the tool + // executor has been silent for longer than staleToolCallMs. These are + // typically abandoned (process killed, executor crashed, or the tool + // simply hung). Without this check, classify returns `continue` forever + // because the part never transitions out of `running`, creating an + // unbounded loop with no other exit path. if ( input.parts.some( (part) => part.type === "tool" && !part.metadata?.providerExecuted && - part.state.status !== "error", + part.state.status !== "error" && + !(part.state.status === "running" && staleMs > 0 && assistant.time?.created && now - assistant.time.created > staleMs), ) ) return { type: "continue" } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2fa3126d..0368a12b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -99,7 +99,7 @@ export function recallHintLines(toolCfg: ToolStyleConfig | undefined): string[] * actors' MAX_PRE_REACT (=3) because main-session goals are usually larger. * TODO: lift to mimocode.json config (e.g. session.maxGoalReact). */ -const MAX_GOAL_REACT = 12 +const MAX_GOAL_REACT = 5 /** * Number of consecutive finished assistant steps with an identical action @@ -108,6 +108,18 @@ const MAX_GOAL_REACT = 12 */ const REPEATED_STEP_THRESHOLD = 3 +/** + * Absolute safety cap on runLoop iterations per turn. Regardless of which + * continuation path (tool observation, taskGate, goalGate, overflow, etc.) + * drives the re-entry, the loop MUST terminate after this many iterations. + * Without this, a combination of paths (taskGate(3) + goalGate(5) + autoContinue + * + overflow) can sum to 15+ re-entries, each involving an LLM API call that + * takes seconds — the user perceives an infinite loop. The cap is intentionally + * generous (50) so it never clips a legitimate long-running agent task, but + * tight enough to guarantee termination within a few minutes at worst. + */ +const MAX_TOTAL_STEPS = 50 + /** * Deterministic JSON serialization with sorted object keys, so that two * semantically-identical tool inputs produce the same string regardless of the @@ -2118,6 +2130,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) while (true) { + // P0 fix: absolute safety cap — regardless of which continuation path + // (tool observation, taskGate, goalGate, overflow, autoContinue, etc.) + // drives the re-entry, the loop MUST terminate after MAX_TOTAL_STEPS. + if (step >= MAX_TOTAL_STEPS) { + yield* slog.warn("runLoop hit MAX_TOTAL_STEPS; forcing break", { step, MAX_TOTAL_STEPS }) + break + } // F55: only main agent sets session status to busy; subagent runners // must not touch session-level status (Runner.onBusy is Effect.void // for non-main actors per F47). diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a65377c5..20409450 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -19,6 +19,7 @@ export const Info = z z.object({ type: z.literal("busy"), message: z.string().optional(), + startedAt: z.number().optional(), }), ]) .meta({ @@ -71,6 +72,16 @@ export const layer = Layer.effect( const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { const data = yield* InstanceState.get(state) + // When transitioning to busy, stamp startedAt so the TUI can show elapsed time. + // Preserve the existing startedAt if the caller is re-setting busy (e.g. runLoop + // sets busy on each iteration) so the timer doesn't reset mid-turn. + if (status.type === "busy" && status.startedAt === undefined) { + const existing = data.get(sessionID) + status = { + ...status, + startedAt: existing?.type === "busy" && existing.startedAt ? existing.startedAt : Date.now(), + } + } yield* bus.publish(Event.Status, { sessionID, status }) if (status.type === "idle") { yield* bus.publish(Event.Idle, { sessionID }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5dff0412..ddf21919 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -463,6 +463,7 @@ export type SessionStatus = | { type: "busy" message?: string + startedAt?: number } export type EventSessionStatus = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c9357161..7e85cc28 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -571,6 +571,7 @@ export type SessionStatus = | { type: "busy" message?: string + startedAt?: number } export type EventSessionStatus = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 662eef8c..0c91e5a5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9740,6 +9740,9 @@ }, "message": { "type": "string" + }, + "startedAt": { + "type": "number" } }, "required": ["type"] diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 57797543..630c4f45 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -4,6 +4,8 @@ type Keys = keyof typeof en export const dict = { "ui.sessionReview.title": "会话变更", + "ui.sessionReview.title.git": "Git 变更", + "ui.sessionReview.title.branch": "分支变更", "ui.sessionReview.title.lastTurn": "上一轮变更", "ui.sessionReview.diffStyle.unified": "统一", "ui.sessionReview.diffStyle.split": "拆分", @@ -41,6 +43,10 @@ export const dict = { "ui.sessionTurn.steps.hide": "隐藏步骤", "ui.sessionTurn.summary.response": "回复", "ui.sessionTurn.diff.showMore": "显示更多更改({{count}})", + "ui.sessionTurn.diffs.changed": "已变更", + "ui.sessionTurn.diffs.showAll": "显示全部", + "ui.sessionTurn.diffs.showLess": "收起", + "ui.sessionTurn.diffs.more": "+{{count}} 个文件", "ui.sessionTurn.retry.retrying": "重试中", "ui.sessionTurn.retry.inSeconds": "{{seconds}} 秒后", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 2d433b57..179cacd5 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -4,6 +4,8 @@ type Keys = keyof typeof en export const dict = { "ui.sessionReview.title": "工作階段變更", + "ui.sessionReview.title.git": "Git 變更", + "ui.sessionReview.title.branch": "分支變更", "ui.sessionReview.title.lastTurn": "上一輪變更", "ui.sessionReview.diffStyle.unified": "整合", "ui.sessionReview.diffStyle.split": "拆分", @@ -41,6 +43,10 @@ export const dict = { "ui.sessionTurn.steps.hide": "隱藏步驟", "ui.sessionTurn.summary.response": "回覆", "ui.sessionTurn.diff.showMore": "顯示更多變更 ({{count}})", + "ui.sessionTurn.diffs.changed": "已變更", + "ui.sessionTurn.diffs.showAll": "顯示全部", + "ui.sessionTurn.diffs.showLess": "收起", + "ui.sessionTurn.diffs.more": "+{{count}} 個檔案", "ui.sessionTurn.retry.retrying": "重試中", "ui.sessionTurn.retry.inSeconds": "{{seconds}} 秒後", diff --git a/run.bat b/run.bat new file mode 100644 index 00000000..b617ec57 --- /dev/null +++ b/run.bat @@ -0,0 +1,22 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal + +set "MIMOCODE_HOME=%~dp0.dev-home" + +where bun >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] bun not found. Please install Bun. + pause + exit /b 1 +) + +set "WORK_DIR=%~dp0" +if not "%~1"=="" ( + set "WORK_DIR=%~1" +) + +:: 启动时把 WORK_DIR 作为 project 参数传入 CLI +bun run --cwd "%~dp0packages\opencode" --conditions=browser src\index.ts "%WORK_DIR%" + +endlocal \ No newline at end of file diff --git a/start.bat b/start.bat new file mode 100644 index 00000000..89a88008 --- /dev/null +++ b/start.bat @@ -0,0 +1,13 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal + +set "MIMOCODE_HOME=%~dp0.dev-home" + +set "WORK_DIR=%~dp0" +if not "%~1"=="" set "WORK_DIR=%~1" +if "%WORK_DIR:~-1%"=="\" set "WORK_DIR=%WORK_DIR:~0,-1%" + +bun run --cwd "%~dp0packages\opencode" --conditions=browser src\index.ts "%WORK_DIR%" + +endlocal \ No newline at end of file