diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md new file mode 100644 index 0000000..7f9cf35 --- /dev/null +++ b/.deepcode/AGENTS.md @@ -0,0 +1,126 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +``` +src/ +├── cli.tsx # Entry point — parses args (-p, -v), renders Ink App +├── session.ts # SessionManager — LLM loop, compaction, tool orchestration +├── settings.ts # Settings resolution from ~/.deepcode/settings.json +├── prompt.ts # System prompt builder, tool definitions, built-in skills +├── common/ +│ ├── model-capabilities.ts # Model detection and thinking-mode defaults +│ ├── openai-thinking.ts # OpenAI thinking request options builder +│ ├── file-utils.ts # File read/write with encoding and diff preview +│ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) +│ ├── state.ts # In-memory file state and snippet tracking +│ ├── runtime.ts # Tool validation runtime helpers +│ ├── notify.ts # Desktop notification after LLM turn completion +│ ├── debug-logger.ts # Debug logging for OpenAI API calls +│ └── error-logger.ts # API error logging +├── ui/ +│ ├── App.tsx # Root Ink component — state, routing, session orchestration +│ ├── PromptInput.tsx # Multi-line input with file mentions (@), slash commands, image paste, skills +│ ├── MessageView.tsx # Renders assistant/tool messages with markdown +│ ├── McpStatusList.tsx # MCP server connection status and available tools +│ ├── ProcessStdoutView.tsx # Ctrl+O fullscreen overlay for live process stdout +│ ├── UpdatePrompt.tsx # UpdatePlan task list progress display +│ ├── fileMentions.ts # @-mention file scanning, filtering, and insertion +│ └── ... +├── mcp/ +│ ├── mcp-client.ts # MCP client — JSON-RPC communication with MCP servers +│ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution, status +├── tools/ +│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers (7 built-in) +│ ├── bash-handler.ts # Executes shell commands with live stdout streaming +│ ├── read-handler.ts # Reads files, images, PDFs, and notebooks +│ ├── write-handler.ts # Creates/overwrites files +│ ├── edit-handler.ts # Scoped string replacements with snippet tracking +│ ├── update-plan-handler.ts # Updates the task plan progress display +│ ├── web-search-handler.ts # Web search via natural language queries +│ └── ask-user-question-handler.ts # Interactive user prompts with options +├── tests/ # One *.test.ts per source module, plus run-tests.mjs +templates/ +├── tools/ # Tool descriptions fed to the LLM +├── skills/ # Built-in skill definitions (agent-drift-guard, plan-and-execute) +├── prompts/ # EJS templates (e.g., init_command.md.ejs) +docs/ # User-facing documentation (configuration, MCP, skills) +dist/ # Bundled CLI output (gitignored) +``` + +## Build, Test, and Development Commands + +| Command | Purpose | +|---|---| +| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) | +| `npm run lint` | ESLint across `src/` | +| `npm run lint:fix` | ESLint with auto-fix | +| `npm run format` | Prettier on all `src/**/*.{ts,tsx}` | +| `npm run format:check` | Prettier in check-only mode | +| `npm run check` | Runs typecheck + lint + format:check together | +| `npm run bundle` | esbuild bundles `src/cli.tsx` → `dist/cli.js` (ESM, Node 18) | +| `npm run build` | `check` + `bundle` + chmod 755 — full CI gate before publish | +| `npm test` | Runs all tests via `tsx --test src/tests/*.test.ts` | +| `npm run test:single -- ` | Run a single test file (e.g., `npm run test:single -- src/tests/session.test.ts`) | + +Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundle`). + +## Coding Style & Naming Conventions + +- **Indentation**: 2 spaces, no tabs +- **Quotes**: Double quotes (`"`) +- **Semicolons**: Required +- **Trailing commas**: `es5` (objects, arrays, etc.) +- **Line width**: 120 characters max +- **Line endings**: LF only + +**TypeScript**: Strict mode enabled. Use `import type` for type-only imports (enforced by `@typescript-eslint/consistent-type-imports`). Unused variables prefixed with `_` are allowed. + +**Formatting/Linting**: Prettier + ESLint (typescript-eslint, react-hooks). Run `npm run check` before pushing. On commit, Husky + lint-staged auto-formats staged `*.{ts,tsx,js,mjs,cjs,ejs,jsx}` and `*.json` files. + +**File naming**: `kebab-case.ts` for modules, `kebab-case.tsx` for React/Ink components. Test files: `*.test.ts`. + +## Testing Guidelines + +- **Framework**: Node.js native test runner (`node:test`) with `tsx` for TypeScript +- **Assertions**: `node:assert/strict` +- **Coverage**: Target meaningful unit tests for core logic (session management, tool handlers, settings resolution, prompt buffer). Test files are in `src/tests/` matching the source module name. +- **Test naming**: `describe`/`test` blocks with descriptive names. Example: `test("SessionManager preserves structured system content when building OpenAI messages", ...)` +- **Relaxed lint rules**: Test files allow `any` and unused vars. +- Run all tests with `npm test` before submitting a PR. A cross-platform test runner is available at `src/tests/run-tests.mjs`. + +## Commit & Pull Request Guidelines + +**Commit messages** follow conventional commits. From the project history: + +- `feat:` — new feature (e.g., `feat: add /model command`) +- `fix:` — bug fix (e.g., `fix(ui): redraw cleanly after terminal resize`) +- `chore:` — tooling, deps, hooks (e.g., `chore: add husky + lint-staged`) +- `refactor:` — code restructuring (e.g., `refactor(ui): optimize App hooks`) +- `style:` — formatting-only changes (e.g., `style: adjust the tree structure symbols`) +- `docs:` — documentation (e.g., `docs: add MCP configuration guide`) + +**Pull requests** should include: +- A clear description of what changed and why +- Link to related issue(s) if applicable +- Screenshots or terminal recordings for UI changes +- All checks passing (`npm run check && npm test`) +- No unintended changes to `dist/` or `package-lock.json` without justification + +## Architecture Overview + +The CLI (`@vegamo/deepcode-cli`) renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). + +Seven built-in tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `templates/tools/`. The `UpdatePlan` tool enables the LLM to display and update a structured task list in the terminal. + +**Slash commands**: `/model`, `/new`, `/init`, `/resume`, `/continue`, `/mcp`, `/exit`, plus dynamic `/skill-name` for each loaded skill. + +**Key UI features**: `@` file mentions in the prompt input (scans project files), `Ctrl+O` to view live process stdout in fullscreen, `Ctrl+V` to paste images, MCP server status display. + +**CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-v` / `--version`, `-h` / `--help`. + +## Agent-Specific Instructions + +- **AGENTS.md loading**: The CLI loads agent instructions from `./AGENTS.md`, `./.deepcode/AGENTS.md`, or `~/.deepcode/AGENTS.md` (first found wins). Write project-specific guidance for the LLM in any of these. +- **Skills**: Place skill definitions in `~/.agents/skills//SKILL.md` (user-level) or `./.agents/skills//SKILL.md` (project-level). Legacy path `./.deepcode/skills/` is also supported. Each SKILL.md uses YAML frontmatter with `name` and `description` fields. +- **Built-in skills**: `agent-drift-guard` (detects and corrects execution drift) and `plan-and-execute` (structured task planning with progress tracking). Both are defined in `templates/skills/` and always injected into every session. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..18f5c60 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings to LF across all platforms +* text=auto eol=lf + +# Binary files should not be normalized +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.eot binary +*.ttf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4dc891f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + branches: [main] + +jobs: + build-and-test: + name: Node ${{ matrix.node-version }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + node-version: + - "20" + - "22" + - "24" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: TypeCheck + Lint + Format Check + run: npm run check + + - name: Bundle + run: npm run bundle + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore index 11b67ce..8f054d4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .vscode/ *.tgz *.log +.deepcode/settings.json diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 0000000..4754b68 --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,12 @@ +{ + "*.{ts,tsx,js,mjs,cjs,jsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.json": [ + "prettier --write" + ], + ".prettierrc": [ + "prettier --write" + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..84dd052 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +*.log +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0b297e4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 120, + "endOfLine": "lf" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44a3a24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `src/` contains the TypeScript CLI implementation, with tool handlers in `src/tools/`, MCP integration in `src/mcp/`, UI components in `src/ui/`, and shared helpers in `src/common/`. +- `src/tests/` contains Node test files named `*.test.ts`. +- `templates/` contains runtime prompt assets: `templates/prompts/` for EJS prompt templates and `templates/tools/` for tool instruction Markdown loaded into the system prompt. +- `docs/` is reserved for user-facing documentation such as configuration and MCP guides. +- `resources/` stores static images used by the documentation or UI. + +## Build, Test, and Development Commands + +- `npm test` runs all test files with `tsx --test`. +- `npm run test:single -- src/tests/.test.ts` runs one test file. +- `npm run typecheck` verifies TypeScript types without emitting files. +- `npm run lint` checks ESLint rules for `src/`. +- `npm run build` runs checks, bundles `src/cli.tsx` to `dist/cli.js`, and marks the bundle executable. + +## Coding Style & Naming Conventions + +- Use TypeScript ES modules and keep imports explicit. +- Prefer small, focused functions; keep filesystem path construction centralized when a path is reused. +- Use two-space indentation and Prettier-compatible formatting. +- Respond in standard technical English. Avoid nonstandard phrasing and corporate jargon. + +## Testing Guidelines + +- Add or update tests in `src/tests/` when changing command behavior, prompt rendering, session flow, tools, or settings. +- Prefer Node's built-in `node:test` and `node:assert/strict` APIs, matching the existing tests. +- Keep tests deterministic by using temporary directories and mocked network calls where needed. + +## Commit & Pull Request Guidelines + +- Keep commits focused on a single change and use concise, imperative commit messages. +- In pull requests, describe the behavior change, list verification commands, and note any packaging or template path changes. diff --git a/README-en.md b/README-en.md new file mode 100644 index 0000000..c1d4acb --- /dev/null +++ b/README-en.md @@ -0,0 +1,203 @@ +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + +English · [中文](./README.md) + +
+
+ +[Deep Code](https://github.com/lessweb/deepcode-cli) is a terminal AI coding assistant optimized for the `deepseek-v4` model, with support for deep thinking, reasoning effort control, Agent Skills, and MCP (Model Context Protocol) integration. + + +## Installation + +```bash +npm install -g @vegamo/deepcode-cli +``` + +Run `deepcode` inside any project directory to get started. + +![intro2](resources/intro2.png) + +## Configuration + +Create `~/.deepcode/settings.json`: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +The configuration file is shared with the [Deep Code VSCode extension](https://github.com/lessweb/deepcode) — configure once, use everywhere. + +For complete configuration details (multi-level priority, environment variables, etc.), see [docs/configuration.md](docs/configuration.md). + +## Key Features + +### **Skills** +Deep Code CLI supports agent skills that allow you to extend the assistant's capabilities: + +- **User-level Skills**: discovered and activated from `~/.agents/skills/`. +- **Project-level Skills**: loaded from `./.agents/skills/` for project-specific workflows, with legacy `./.deepcode/skills/` compatibility. + +### **Optimized for DeepSeek** +- Specifically tuned for DeepSeek model performance. +- Reduce costs by using [Context Caching](https://api-docs.deepseek.com/guides/kv_cache). +- Natively supports [Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode) and Effort Control. + +## Slash Commands & Keyboard Shortcuts + +| Slash Command | Action | +|------------------|---------------------------------------------------------| +| `/` | Open the skills / commands menu | +| `/new` | Start a fresh conversation | +| `/resume` | Choose a previous conversation to continue | +| `/continue` | Continue the active conversation or pick one to resume | +| `/model` | Switch model, thinking mode, and reasoning effort | +| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) | +| `/init` | Initialize an AGENTS.md file (LLM project instructions) | +| `/skills` | List available skills | +| `/mcp` | View MCP server status and available tools | +| `/undo` | Restore code and/or conversation to a previous point | +| `/exit` | Quit (also `Ctrl+D` twice) | + +| Key | Action | +|------------------|----------------------------------------------------------| +| `Enter` | Send the prompt | +| `Shift+Enter` | Insert a newline (also `Ctrl+J`) | +| `Ctrl+V` | Paste an image from the clipboard | +| `Esc` | Interrupt the current model turn | +| `Ctrl+D` twice | Quit Deep Code | + +## Supported Models + +- `deepseek-v4-pro` (Recommended) +- `deepseek-v4-flash` +- Any other OpenAI-compatible model + +## FAQ + +### Does Deep Code have a VSCode extension? + +Yes. Deep Code offers a full-featured VSCode extension, available on the [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode). The extension shares the `~/.deepcode/settings.json` configuration file with the CLI, so you can switch seamlessly between the terminal and the editor. + +### Does Deep Code support understanding images? + +Deep Code supports multimodal input — you can paste images from the clipboard with `Ctrl+V`. However, `deepseek-v4` does not support multimodal yet. Some models have multimodal capabilities but impose strict limits on multi-turn dialogue requests. For multimodal input, we recommend using the Volcano Ark `Doubao-Seed-2.0-pro` model, which has the best integration. + +### How to automatically send a Slack message after a task completes? + +Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, see [docs/notify_en.md](docs/notify_en.md). + +### How do I enable web search? + +Deep Code comes with a built-in, free Web Search tool that works well for most use cases. If you prefer to use a custom script for web search, set the `webSearchTool` field in `~/.deepcode/settings.json` to the full path of your script. For detailed steps, refer to: https://github.com/qorzj/web_search_cli + +### Does it support Coding Plan? + +Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compatible API endpoint. Take Volcano Ark's Coding Plan as an example: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +### How do I configure MCP? + +Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. + +For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) + +### How to configure Deep Code to send notifications after a task completes? + +When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the task results to the specified channel (e.g., Slack, system notifications, etc.). + +For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md) + +### Does Deep Code only support YOLO mode? + +No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission.md](docs/permission.md) for details. + +## Contributing + +Contributions are welcome! Here's how to get started: + +```bash +# Clone the repository +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# Install dependencies +npm install + +# Local development (typecheck + lint + format check + bundle) +npm run build + +# Run tests +npm test + +# Link globally (local global install) +npm link +``` + +- Make sure `npm run check` passes before submitting a PR (typecheck + lint + format check) +- We recommend running `npm run format` before building to avoid errors + +## Getting Help + +- Report bugs or request features on GitHub Issues (https://github.com/lessweb/deepcode-cli/issues) + +## License + +- MIT + +## Support Us + +If you find this tool helpful, please consider supporting us by: + +- Giving us a Star on GitHub (https://github.com/lessweb/deepcode-cli) +- Submitting feedback and suggestions +- Sharing with your friends and colleagues + + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 \ No newline at end of file diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 0000000..2643756 --- /dev/null +++ b/README-zh_CN.md @@ -0,0 +1,202 @@ +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + +[English](README-en.md) · 中文 + +
+
+ +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 + +## 安装 + +```bash +npm install -g @vegamo/deepcode-cli +``` + +在任意项目目录下运行 `deepcode` 即可启动。 + +![intro2](resources/intro2.png) + +## 配置 + +创建 `~/.deepcode/settings.json` 文件,内容如下: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 + +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + +## 主要功能 + +### **Skills** +Deep Code CLI 支持 agent skills,允许您扩展助手的能力: + +- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 + +### **为 DeepSeek 优化** +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|---------------|--------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + + +## 常见问题 + +### Deep Code 是否有 VSCode 插件? + +有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 + +### Deep Code 是否支持理解图片? + +Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 + +### 怎样在任务完成后自动给 Slack 发消息? + +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 + +### 怎样启用联网搜索功能? + +Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli + +### 如何配置 MCP? + +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 + +详细配置指南:[docs/mcp.md](docs/mcp.md) + +### 如何配置 Deep Code 任务完成后发送通知? + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +详细配置指南:[docs/notify.md](docs/notify.md) + +### Deep Code 只支持 YOLO 模式吗? + +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 + +### 是否支持 Coding Plan? + +支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## 贡献 + +欢迎贡献代码!以下是参与方式: + +```bash +# 克隆仓库 +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# 安装依赖 +npm install + +# 本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# 运行测试 +npm test + +# 链接到全局(即本地全局安装) +npm link +``` + +- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) +- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 + +## 获取帮助 + +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) + +## 协议 + +- MIT + +## 支持我们 + +如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 \ No newline at end of file diff --git a/README.md b/README.md index 7109f43..2643756 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,36 @@ -# Deep Code CLI +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

-Vibe coding for the deepseek-v4 model, in your terminal. +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] -## Install +[English](README-en.md) · 中文 -```sh +
+
+ +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 + +## 安装 + +```bash npm install -g @vegamo/deepcode-cli ``` -Then run `deepcode` inside any project directory. +在任意项目目录下运行 `deepcode` 即可启动。 -![intro1](resources/intro1.png) +![intro2](resources/intro2.png) -## Configure +## 配置 -Create `~/.deepcode/settings.json`: +创建 `~/.deepcode/settings.json` 文件,内容如下: ```json { @@ -28,44 +44,159 @@ Create `~/.deepcode/settings.json`: } ``` -The same settings file is shared with the [Deep Code VSCode extension](https://github.com/lessweb/deepcode). +配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 + +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + +## 主要功能 + +### **Skills** +Deep Code CLI 支持 agent skills,允许您扩展助手的能力: + +- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 + +### **为 DeepSeek 优化** +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|---------------|--------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + + +## 常见问题 + +### Deep Code 是否有 VSCode 插件? + +有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 + +### Deep Code 是否支持理解图片? + +Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 + +### 怎样在任务完成后自动给 Slack 发消息? + +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 + +### 怎样启用联网搜索功能? + +Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli -## Skills +### 如何配置 MCP? -Skills live in: +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 -- `~/.agents/skills//SKILL.md` (user-level) -- `./.deepcode/skills//SKILL.md` (project-level) +详细配置指南:[docs/mcp.md](docs/mcp.md) -Inside the TUI press `/` to open the skill picker, or type the skill name directly (e.g. `/skill-writer`). +### 如何配置 Deep Code 任务完成后发送通知? -## Keys +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 -| Key | Action | -|-----------------|----------------------------------------------| -| `Enter` | Send the prompt | -| `Shift+Enter` | Insert a newline (also `Ctrl+J`) | -| `Ctrl+V` | Paste an image from the clipboard | -| `Esc` | Interrupt the current model turn | -| `/` | Open the skills / commands menu | -| `/new` | Start a fresh conversation | -| `/resume` | Choose a previous conversation to continue | -| `/skills` | List available skills | -| `/exit` | Quit Deep Code | -| `Ctrl+D` twice | Quit Deep Code | +详细配置指南:[docs/notify.md](docs/notify.md) -## Storage +### Deep Code 只支持 YOLO 模式吗? -Every project keeps its history in `~/.deepcode/projects//`: +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 -- `sessions-index.json` — index of conversations -- `.jsonl` — message stream for each conversation +### 是否支持 Coding Plan? -## Develop +支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: -```sh +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## 贡献 + +欢迎贡献代码!以下是参与方式: + +```bash +# 克隆仓库 +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# 安装依赖 npm install -npm run typecheck + +# 本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# 运行测试 npm test -npm run build # produces dist/cli.cjs + +# 链接到全局(即本地全局安装) +npm link ``` + +- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) +- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 + +## 获取帮助 + +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) + +## 协议 + +- MIT + +## 支持我们 + +如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1cce9a1 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,185 @@ +# Deep Code 配置 + +## 配置层级 + +配置按以下优先级顺序应用(数字较小的会被数字较大的覆盖): + +| 层级 | 配置来源 | 说明 | +| ---- | ------------ | ------------------------------------------- | +| 1 | 默认值 | 应用程序内硬编码的默认值 | +| 2 | 用户设置文件 | 当前用户的全局设置 | +| 3 | 项目设置文件 | 项目特定的设置 | +| 4 | 环境变量 | 系统范围或会话特定的变量 | + +## 设置文件 + +Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两个层级的存放位置: + +| 文件类型 | 位置 | 作用范围 | +| ------------ | ---------------------------------- | ---------------------------------------------------- | +| 用户设置文件 | `~/.deepcode/settings.json` | 适用于当前用户的所有 Deep Code 会话。 | +| 项目设置文件 | `项目根目录/.deepcode/settings.json` | 仅在该特定项目中运行 Deep Code 时生效。项目设置会覆盖用户设置。 | + +### `settings.json` 中的可用设置 + +以下是 `settings.json` 支持的全部顶层字段,以及 `env` 内部支持的子字段: + +| 字段 | 类型 | 说明 | +| -------------------- | --------- | ------------------------------------------------------------------- | +| `env` | object | 环境变量分组(见下方子字段表) | +| `model` | string | 模型名称。优先级高于 `env.MODEL` | +| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | +| `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | +| `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | +| `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | +| `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | +| `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | + +#### `env` 子字段 + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------------------------------------------------ | +| `MODEL` | string | 模型名称。例如 `"deepseek-v4-pro"`、`"deepseek-v4-flash"` | +| `BASE_URL` | string | API 请求的基础 URL。例如 `"https://api.deepseek.com"` | +| `API_KEY` | string | API 密钥 | +| `THINKING_ENABLED` | string | 是否启用思考模式 | +| `REASONING_EFFORT` | string | 推理强度 | +| `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | +| `<其他任意KEY>` | string | 自定义环境变量 | + +#### `thinkingEnabled` — 思考模式 + +是否启用 DeepSeek 思考模式。设置为 `true` 启用、`false` 禁用。 + +- 对于 `deepseek-v4-pro` 和 `deepseek-v4-flash`,思考模式**默认启用**。 +- 对于其他模型,思考模式**默认关闭**。 + +#### `reasoningEffort` — 推理强度 + +当思考模式启用时,控制模型思考的深度: + +| 值 | 说明 | +| ------ | --------------------------------- | +| `max` | 最大推理深度(默认值) | +| `high` | 较高推理深度,token消耗相对较小 | + +#### `notify` — 任务完成通知 + +设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 + +通知脚本执行时,会通过环境变量注入以下上下文信息: + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + +```json +{ + "notify": "/path/to/notify-script.sh" +} +``` + +> 详细的 Slack、飞书、终端通知、系统通知等配置示例,请参阅 [notify.md](notify.md)。 + +#### `webSearchTool` — 自定义联网搜索 + +Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: + +```json +{ + "webSearchTool": "/path/to/my-search-script.sh" +} +``` + +脚本接收一个搜索查询参数,输出 JSON 格式的结果供 AI 使用。 + +#### `mcpServers` — MCP 服务器 + +MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 + +```json +{ + "mcpServers": { + "<服务名>": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +| McpServerConfig 字段 | 类型 | 必填 | 说明 | +| -------------------- | -------- | ---- | -------------------------------------------------------------------- | +| `command` | string | 是 | 可执行文件路径或命令(如 `npx`、`node`、`python`) | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量 | + +> 当 `command` 为 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 + +详细 MCP 使用说明请参考 [mcp.md](mcp.md)。 + + +#### `debugLogEnabled` — 调试日志 + +设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 + +## 环境变量优先级 + +环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 + +### 优先级原则 + +环境变量优先级遵循“越具体、越局部的配置,优先级越高”和“env文件默认保护现有环境,系统变量高于env文件”的覆盖逻辑。(settings.json的env对象可以认为是一种env文件) + +优先级层级 (由低到高) +1. settings.json 外层的 env:这是针对整个工具及其所有子进程的通用配置(全局变量)。可被外层环境变量覆盖,但环境变量KEY会移除`DEEPCODE_`前缀。 +2. settings.json mcpServers 内定义的 env:这是针对特定 MCP 服务的最具体配置(局部变量)。可被外层环境变量覆盖,但环境变量KEY会移除`MCP_`前缀。 +3. Shell 环境系统变量:操作系统层面的环境变量。 + +### 场景 + +#### 一、设置模型的api_key, base_url + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以api_key为例): + +1. 硬编码默认值: `""` +2. 用户级settings.json: `{"env": {"API_KEY": "abc123"}}` +3. 项目级settings.json: `{"env": {"API_KEY": "abc123"}}` +4. 系统环境变量: `DEEPCODE_API_KEY=abc123 deepcode` + +#### 二、设置模型的model, thinkingEnabled, reasoningEffort + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以thinkingEnabled为例): + +1. 硬编码默认值: `true` +2. 用户级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. 用户级settings.json: `{"thinkingEnabled": true}` +4. 项目级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. 项目级settings.json: `{"thinkingEnabled": true}` +6. 系统环境变量: `DEEPCODE_THINKING_ENABLED=true deepcode` + +#### 三、设置启动notify, webSearchTool等外挂脚本的环境变量 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以notify为例): + +1. 硬编码默认值:`os.environ.get('WEBHOOK', '...') # notify脚本代码` +2. 用户级settings.json: `{"env": {"WEBHOOK": "..."}}` +3. 项目级settings.json: `{"env": {"WEBHOOK": "true"}}` +4. 系统环境变量: `DEEPCODE_WEBHOOK=... deepcode` + +#### 四、设置MCP Service的环境变量 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以github MCP server为例): + +1. 用户级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. 用户级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/docs/configuration_en.md b/docs/configuration_en.md new file mode 100644 index 0000000..fa396f9 --- /dev/null +++ b/docs/configuration_en.md @@ -0,0 +1,184 @@ +# Deep Code Configuration + +## Configuration Hierarchy + +Configuration is applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones): + +| Layer | Configuration Source | Description | +| ----- | -------------------- | ---------------------------------------------- | +| 1 | Defaults | Hardcoded defaults within the application | +| 2 | User settings file | Global settings for the current user | +| 3 | Project settings file| Project-specific settings | +| 4 | Environment variables| System-wide or session-specific variables | + +## Settings File + +Deep Code uses the `settings.json` file for persistent configuration, supporting two storage locations: + +| File Type | Location | Scope | +| ------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| User settings file | `~/.deepcode/settings.json` | Applies to all Deep Code sessions for the current user. | +| Project settings file | `/.deepcode/settings.json` | Takes effect only when running Deep Code in that specific project. Project settings override user settings. | + +### Available Settings in `settings.json` + +The following are all the top-level fields supported in `settings.json`, along with the sub-fields inside `env`: + +| Field | Type | Description | +| ------------------ | ------- | --------------------------------------------------------------------------- | +| `env` | object | Group of environment variables (see sub-field table below) | +| `model` | string | Model name. Takes precedence over `env.MODEL` | +| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| +| `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | +| `debugLogEnabled` | boolean | Enable debug log output (default `false`) | +| `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | +| `webSearchTool` | string | Full path to a custom web search script | +| `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | + +#### `env` Sub-fields + +| Field | Type | Description | +| ----------------- | ------ | ---------------------------------------------------------------- | +| `MODEL` | string | Model name, e.g. `"deepseek-v4-pro"`, `"deepseek-v4-flash"` | +| `BASE_URL` | string | Base URL for API requests, e.g. `"https://api.deepseek.com"` | +| `API_KEY` | string | API key | +| `THINKING_ENABLED`| string | Enable thinking mode | +| `REASONING_EFFORT`| string | Reasoning intensity | +| `DEBUG_LOG_ENABLED`| string| Enable debug log output | +| `` | string | Custom environment variable | + +#### `thinkingEnabled` — Thinking Mode + +Whether to enable DeepSeek thinking mode. Set to `true` to enable, `false` to disable. + +- For `deepseek-v4-pro` and `deepseek-v4-flash`, thinking mode is **enabled by default**. +- For other models, thinking mode is **disabled by default**. + +#### `reasoningEffort` — Reasoning Intensity + +When thinking mode is enabled, controls the depth of the model’s reasoning: + +| Value | Description | +| ------ | --------------------------------------------------------- | +| `max` | Maximum reasoning depth (default) | +| `high` | Higher reasoning depth with relatively lower token usage | + +#### `notify` — Task Completion Notification + +Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). + +The following context is injected as environment variables when the notify script runs: + +| Variable | Description | +|----------|-------------| +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + +```json +{ + "notify": "/path/to/notify-script.sh" +} +``` + +> For detailed configuration examples (Slack, Feishu, terminal notifications, system notifications, etc.), see [notify_en.md](notify_en.md). + +#### `webSearchTool` — Custom Web Search + +Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: + +```json +{ + "webSearchTool": "/path/to/my-search-script.sh" +} +``` + +The script receives a search query as an argument and outputs results in JSON format for the AI. + +#### `mcpServers` — MCP Servers + +Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. + +```json +{ + "mcpServers": { + "": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +| McpServerConfig field | Type | Required | Description | +| --------------------- | -------- | -------- | ------------------------------------------------------------------------ | +| `command` | string | Yes | Executable path or command (e.g. `npx`, `node`, `python`) | +| `args` | string[] | No | List of arguments passed to the command | +| `env` | object | No | Environment variables passed to the MCP server process | + +> When `command` is `npx`, Deep Code automatically prepends `-y` to the arguments. + +For detailed MCP usage instructions, refer to [mcp.md](mcp.md). + +#### `debugLogEnabled` — Debug Log + +Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. + +## Environment Variable Priority + +Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. + +### Priority Principle + +Environment variable priority follows the logic of “the more specific and localized the configuration, the higher the priority”, and the override rule of “env files protect existing environment by default, system variables override env files”. (The `env` object in settings.json can be thought of as a type of env file.) + +Priority levels (from lowest to highest): +1. `env` defined at the top level of `settings.json` – this is a general configuration for the entire tool and all its subprocesses (global variables). Can be overridden by outer environment variables, but the environment variable KEY has the `DEEPCODE_` prefix removed. +2. `env` defined inside `mcpServers` in `settings.json` – this is the most specific configuration for a particular MCP service (local variables). Can be overridden by outer environment variables, but the KEY has the `MCP_` prefix removed. +3. Shell/system environment variables – operating system level. + +### Scenarios + +#### 1. Setting the model’s api_key and base_url + +Applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones) – using api_key as an example: + +1. Hardcoded default: `""` +2. User-level settings.json: `{"env": {"API_KEY": "abc123"}}` +3. Project-level settings.json: `{"env": {"API_KEY": "abc123"}}` +4. System environment variable: `DEEPCODE_API_KEY=abc123 deepcode` + +#### 2. Setting model, thinkingEnabled, and reasoningEffort + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using thinkingEnabled as an example: + +1. Hardcoded default: `true` +2. User-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. User-level settings.json: `{"thinkingEnabled": true}` +4. Project-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. Project-level settings.json: `{"thinkingEnabled": true}` +6. System environment variable: `DEEPCODE_THINKING_ENABLED=true deepcode` + +#### 3. Setting environment variables for external scripts like notify and webSearchTool + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using notify as an example: + +1. Hardcoded default: `os.environ.get('WEBHOOK', '...') # notify script code` +2. User-level settings.json: `{"env": {"WEBHOOK": "..."}}` +3. Project-level settings.json: `{"env": {"WEBHOOK": "true"}}` +4. System environment variable: `DEEPCODE_WEBHOOK=... deepcode` + +#### 4. Setting environment variables for an MCP Service + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using a GitHub MCP server as an example: + +1. User-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..73034a3 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,200 @@ +# Deep Code CLI MCP 配置指南 + +Deep Code CLI 支持 MCP(Model Context Protocol),让 AI 助手能够连接外部工具和服务,如 GitHub、浏览器、数据库等。 + +## 概述 + +配置 MCP 后,Deep Code 可以: + +- 操作 GitHub 仓库(查看 Issues、创建 PR、搜索代码等) +- 操控浏览器(截图、点击、填表单等) +- 访问文件系统 +- 连接数据库和 API +- ...以及任何兼容 MCP 协议的外部服务 + +MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`,例如 `mcp__github__search_code`。 + +## 配置 MCP 服务器 + +编辑 `~/.deepcode/settings.json`,添加 `mcpServers` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "<服务名称>": { + "command": "<可执行文件>", + "args": ["<参数1>", "<参数2>"], + "env": { + "<环境变量>": "<值>" + } + } + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 必填 | 说明 | +| --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | + +## 常用 MCP 示例 + +### GitHub MCP + +让 Deep Code 直接操作 GitHub 仓库(搜索代码、管理 Issue/PR、读写文件等): + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +> GitHub Personal Access Token 可在 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) 生成。 + +### 浏览器控制(Playwright) + +让 Deep Code 操控浏览器进行截图、页面操作等: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +### 文件系统 + +让 Deep Code 在指定目录中读写文件: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"] + } + } +} +``` + +### 自定义 Python MCP + +```json +{ + "mcpServers": { + "my-tool": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "API_KEY": "xxx" + } + } + } +} +``` + +## 完整配置示例 + +以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整 `~/.deepcode/settings.json`: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-xxxxxxxxxxxx" + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +## 使用 MCP + +配置完成后,启动 `deepcode`,在聊天中输入 `/mcp` 即可查看所有已配置的 MCP 服务器状态以及每个服务器提供的工具列表。 + +在对话中直接使用 MCP 工具名称即可调用,例如: + +``` +帮我搜索 GitHub 上 deepcode-cli 仓库的 issues +``` + +AI 会自动调用 `mcp__github__search_issues` 工具完成操作。 + +## 工具命名规则 + +MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` + +| 服务名 | 工具名 | 完整调用名 | +| ---------- | ----------------------- | ------------------------------------------ | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | + +你可以通过 `/mcp` 查看每个服务器提供的具体工具列表。 + +## 故障排查 + +### 启动失败 + +如果 MCP 服务器无法启动,检查: + +1. `command` 是否已安装(如 `npx` 需要 Node.js) +2. `env` 中的环境变量是否正确(如 `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. 运行 `deepcode` 的终端是否有网络访问权限 + +### 工具不显示 + +1. 确认 `settings.json` 中的 `mcpServers` 字段格式正确 +2. 启动 deepcode 后使用 `/mcp` 查看服务器状态 +3. 如果服务器状态显示错误,根据错误信息排查 + +### Windows 用户 + +在 Windows 上,Deep Code CLI 会自动为 `.cmd` 命令添加 shell 支持。如果你的 MCP 命令是批处理脚本,确保文件名以 `.cmd` 结尾。 + +## 编写你自己的 MCP 服务器 + +MCP 服务器遵循 [Model Context Protocol](https://modelcontextprotocol.io/) 规范,使用 JSON-RPC 2.0 通信。你可以用任何语言编写 MCP 服务器,只要实现以下协议即可: + +1. `initialize` — 握手和协议协商 +2. `tools/list` — 返回可用工具列表 +3. `tools/call` — 执行工具调用 + +更多参考:[MCP 官方文档](https://modelcontextprotocol.io/) diff --git a/docs/mcp_en.md b/docs/mcp_en.md new file mode 100644 index 0000000..03c4b30 --- /dev/null +++ b/docs/mcp_en.md @@ -0,0 +1,200 @@ +# Deep Code CLI MCP Configuration Guide + +Deep Code CLI supports MCP (Model Context Protocol), enabling AI assistants to connect with external tools and services such as GitHub, browsers, databases, and more. + +## Overview + +Once MCP is configured, Deep Code can: + +- Operate on GitHub repositories (view issues, create PRs, search code, etc.) +- Control browsers (screenshots, clicks, form filling, etc.) +- Access the file system +- Connect to databases and APIs +- ...and any external service compatible with the MCP protocol + +MCP tools are named in Deep Code using the format `mcp____`, for example `mcp__github__search_code`. + +## Configuring MCP Servers + +Edit `~/.deepcode/settings.json` and add the `mcpServers` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "": { + "command": "", + "args": ["", ""], + "env": { + "": "" + } + } + } +} +``` + +### Configuration Fields + +| Field | Type | Required | Description | +| --------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | +| `args` | string[] | No | List of arguments to pass to the command | +| `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | + +## Common MCP Examples + +### GitHub MCP + +Allows Deep Code to directly operate on GitHub repositories (search code, manage issues/PRs, read/write files, etc.): + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +> Generate a GitHub Personal Access Token at [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens). + +### Browser Control (Playwright) + +Lets Deep Code control a browser for screenshots, page interactions, etc.: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +### File System + +Enables Deep Code to read and write files within a specified directory: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"] + } + } +} +``` + +### Custom Python MCP + +```json +{ + "mcpServers": { + "my-tool": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "API_KEY": "xxx" + } + } + } +} +``` + +## Full Configuration Example + +Below is a complete `~/.deepcode/settings.json` with both GitHub and Playwright MCP servers configured: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-xxxxxxxxxxxx" + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +## Using MCP + +After configuration, start `deepcode` and type `/mcp` in the chat to view the status of all configured MCP servers and the list of tools each server provides. + +Simply use the MCP tool name in your conversation to invoke it, for example: + +``` +Help me search for issues in the deepcode-cli repository on GitHub +``` + +The AI will automatically invoke the `mcp__github__search_issues` tool to complete the action. + +## Tool Naming Convention + +An MCP tool name consists of three parts: `mcp____` + +| Service | Tool Name | Full Invocation Name | +| ---------- | ----------------------- | ------------------------------------------- | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | + +You can view the list of tools provided by each server using `/mcp`. + +## Troubleshooting + +### Startup Failure + +If an MCP server fails to start, check: + +1. Whether `command` is installed (e.g., `npx` requires Node.js) +2. Whether environment variables in `env` are correct (e.g., `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. Whether the terminal running `deepcode` has network access + +### Tools Not Showing Up + +1. Verify that the `mcpServers` field in `settings.json` is correctly formatted +2. After starting deepcode, use `/mcp` to check server status +3. If the server status shows an error, debug based on the error message + +### Windows Users + +On Windows, Deep Code CLI automatically adds shell support for `.cmd` commands. If your MCP command is a batch script, ensure the filename ends with `.cmd`. + +## Writing Your Own MCP Server + +MCP servers follow the [Model Context Protocol](https://modelcontextprotocol.io/) specification and communicate using JSON‑RPC 2.0. You can write an MCP server in any language as long as it implements the following methods: + +1. `initialize` — Handshake and protocol negotiation +2. `tools/list` — Return the list of available tools +3. `tools/call` — Execute a tool call + +For more information, see the [official MCP documentation](https://modelcontextprotocol.io/). \ No newline at end of file diff --git a/docs/notify.md b/docs/notify.md new file mode 100644 index 0000000..d73eef4 --- /dev/null +++ b/docs/notify.md @@ -0,0 +1,211 @@ +# Deep Code 任务完成通知 + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +## 工作原理 + +在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。 + +## 注入的环境变量 + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + +## 配置方法 + +编辑 `~/.deepcode/settings.json`,添加 `notify` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +你也可以在 `env` 中配置通知脚本所需的自定义环境变量,例如 Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +这些 `env` 中的变量会被注入到脚本的执行环境中。 + +## Slack 通知 + +### 1. 获取 Slack Webhook URL + +1. 创建 [Slack App](https://api.slack.com/apps) +2. 在 App 页面点击 **Incoming Webhooks** → **Add New Webhook to Workspace**,生成 Webhook URL + +### 2. 创建通知脚本 + +创建 `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code 任务已完成\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION 秒\" + }" +``` + +给脚本添加可执行权限: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. 配置 settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> Python 版本的脚本同样支持,你可以在 `env` 中传入并引用任意自定义环境变量。 + +## 飞书 / 企业微信等 Webhook 通知 + +以下示例使用 `node` 构建 JSON(自动转义特殊字符),`curl` 发送。通过 `env` 传入 `WEBHOOK_URL`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。此模式同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。 + +## 终端通知(iTerm2 / Windows Terminal) + +如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。 + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 通知 +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本: + +```batch +@echo off +REM Windows Terminal OSC 9 通知 +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS 系统通知 + +```bash +#!/bin/bash +# macOS 系统通知 +osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux 系统通知 + +需要安装 `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send 通知 +notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg 弹窗通知 + +```batch +@echo off +REM Windows msg 弹窗通知 +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## 自定义通知脚本 + +你可以根据通知脚本注入的环境变量自行编写任意逻辑的通知脚本(Python、Node.js、Ruby 等均可),只要脚本可执行即可。脚本中可通过 `env` 字段传入额外需要的配置变量。 diff --git a/docs/notify_en.md b/docs/notify_en.md new file mode 100644 index 0000000..b949161 --- /dev/null +++ b/docs/notify_en.md @@ -0,0 +1,211 @@ +# Deep Code Task Completion Notification + +When the AI assistant finishes a round of tasks, Deep Code can automatically execute a notification script to send task results to your chosen channel (Slack, system notifications, etc.). + +## How It Works + +Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables. + +## Injected Environment Variables + +| Variable | Description | +|----------|-------------| +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + +## Configuration + +Edit `~/.deepcode/settings.json` and add the `notify` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +You can also configure custom environment variables for the notify script in `env`, such as a Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +These `env` variables are injected into the script's execution environment. + +## Slack Notification + +### 1. Get a Slack Webhook URL + +1. Create a [Slack App](https://api.slack.com/apps) +2. In the App page, go to **Incoming Webhooks** → **Add New Webhook to Workspace** to generate a Webhook URL + +### 2. Create the Notification Script + +Create `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code task completed\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION s\" + }" +``` + +Make the script executable: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. Configure settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> A Python version is also supported; you can pass and reference any custom environment variables via `env`. + +## Feishu / WeCom Webhook Notification + +Use `node` to build JSON (auto-escapes special characters) and `curl` to send. Pass `WEBHOOK_URL` via `env`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +Replace `WEBHOOK_URL` with your Feishu bot webhook URL. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format. + +## Terminal Notification (iTerm2 / Windows Terminal) + +On iTerm2 or Windows Terminal, you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 notification +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows users on Git Bash can use the same script; alternatively, create a `.bat` script: + +```batch +@echo off +REM Windows Terminal OSC 9 notification +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS System Notification + +```bash +#!/bin/bash +# macOS system notification +osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux System Notification + +Requires `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send notification +notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg Popup Notification + +```batch +@echo off +REM Windows msg popup notification +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## Custom Notification Scripts + +You can write your own notification scripts in any language (Python, Node.js, Ruby, etc.) using the injected environment variables and any additional variables passed via `env`. diff --git a/docs/permission.md b/docs/permission.md new file mode 100644 index 0000000..91c19c6 --- /dev/null +++ b/docs/permission.md @@ -0,0 +1,101 @@ +# Deep Code 权限机制 + +Deep Code 内置了一套细粒度的权限控制机制,在 AI 助手执行工具调用(如执行 Shell 命令、读写文件、访问网络等)前,根据用户配置的策略决定是自动放行、直接拒绝、还是弹出交互式确认。 + +## 概述 + +每次 AI 助手调用工具时,系统会自动分析该操作涉及的**权限范围(Permission Scope)**,然后根据 `settings.json` 中的权限配置做出决策。对于需要用户确认的操作,会在终端中弹出交互式选择界面,用户可以选择: + +- **Yes** — 仅本次放行 +- **Yes, and always allow** — 本次放行,并将该权限范围写入项目配置文件,后续同类操作不再询问 +- **No** — 拒绝本次操作 + +## 权限范围 + +Deep Code 定义了以下 10 种权限范围,覆盖了工具调用的各类风险场景: + +| 权限范围 | 说明 | +| -------- | ---- | +| `read-in-cwd` | 读取当前工作区内的文件 | +| `read-out-cwd` | 读取当前工作区外的文件 | +| `write-in-cwd` | 在当前工作区内创建或覆写文件 | +| `write-out-cwd` | 在当前工作区外创建或覆写文件 | +| `delete-in-cwd` | 删除当前工作区内的文件 | +| `delete-out-cwd` | 删除当前工作区外的文件 | +| `query-git-log` | 查询 Git 历史(如 `git log`、`git show`、`git blame`) | +| `mutate-git-log` | 修改 Git 历史(如 `git commit`、`git rebase`、`git tag`) | +| `network` | 访问网络(如 `curl`、`npm install` 等联网操作) | +| `mcp` | 调用 MCP 外部工具 | + +此外还有一个特殊的 `unknown` 范围,当 LLM 无法准确分类命令的副作用时使用,**`unknown` 总是触发询问**。 + +## 权限配置 + +在 `~/.deepcode/settings.json`(用户级)或 `.deepcode/settings.json`(项目级)中通过 `permissions` 字段配置: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| `allow` | `string[]` | 始终自动放行的权限范围列表 | +| `deny` | `string[]` | 始终自动拒绝的权限范围列表 | +| `ask` | `string[]` | 始终弹出询问的权限范围列表 | +| `defaultMode` | `"allowAll"` \| `"askAll"` | 未在 `allow`/`deny`/`ask` 中明确列出的权限范围的默认处理方式。默认为 `"allowAll"` | + +### 优先级规则 + +当一个工具调用涉及多个权限范围时,决策按以下优先级进行: + +1. 若任一范围命中 `deny` → **拒绝** +2. 若任一范围命中 `ask` → **询问** +3. 若所有范围均在 `allow` 中 → **自动放行** +4. 否则 → 按 `defaultMode` 处理 + +### 示例:宽松模式(默认) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +默认行为:所有操作自动放行,无需确认。 + +### 示例:严格模式 + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +此配置的效果: +- 工作区内读写、Git 查询 → 自动放行 +- 其他操作都需要用户确认。 + + +## 持久化机制 + +当用户在权限提示中选择 "Yes, and always allow" 后,对应的权限范围会被写入当前项目的 `.deepcode/settings.json` 文件中: + +- 新增范围会追加到 `permissions.allow` 列表 +- 如果该范围之前存在于 `deny` 或 `ask` 中,会被自动移除 +- 不会重复写入已存在的范围 + +这样后续同类操作就不再询问。 diff --git a/docs/permission_en.md b/docs/permission_en.md new file mode 100644 index 0000000..dae739c --- /dev/null +++ b/docs/permission_en.md @@ -0,0 +1,100 @@ +# Deep Code Permission Mechanism + +Deep Code includes a fine-grained permission control mechanism. Before the AI assistant executes a tool call (such as running a shell command, reading/writing files, accessing the network, etc.), the system determines whether to auto-allow, auto-deny, or prompt for interactive confirmation based on your configured policy. + +## Overview + +Each time the AI assistant invokes a tool, the system automatically analyzes the **permission scopes** involved and makes a decision based on the permission configuration in `settings.json`. For operations requiring user confirmation, an interactive prompt appears in the terminal with the following choices: + +- **Yes** — Allow this one time only +- **Yes, and always allow** — Allow this time and persistently save the scope to the project configuration so future calls skip the prompt +- **No** — Deny this operation + +## Permission Scopes + +Deep Code defines the following 10 permission scopes, covering various risk scenarios for tool calls: + +| Permission Scope | Description | +| ---------------- | ----------- | +| `read-in-cwd` | Read files inside the current workspace | +| `read-out-cwd` | Read files outside the current workspace | +| `write-in-cwd` | Create or overwrite files inside the current workspace | +| `write-out-cwd` | Create or overwrite files outside the current workspace | +| `delete-in-cwd` | Delete files inside the current workspace | +| `delete-out-cwd` | Delete files outside the current workspace | +| `query-git-log` | Query Git history (e.g., `git log`, `git show`, `git blame`) | +| `mutate-git-log` | Mutate Git history (e.g., `git commit`, `git rebase`, `git tag`) | +| `network` | Access the network (e.g., `curl`, `npm install`) | +| `mcp` | Invoke MCP external tools | + +There is also a special `unknown` scope used when the LLM cannot classify a command's side effects — **`unknown` always triggers a prompt**. + +## Permission Configuration + +Configure permissions in `~/.deepcode/settings.json` (user-level) or `.deepcode/settings.json` (project-level) via the `permissions` field: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### Configuration Fields + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `allow` | `string[]` | Permission scopes that are always auto-allowed | +| `deny` | `string[]` | Permission scopes that are always auto-denied | +| `ask` | `string[]` | Permission scopes that always trigger a confirmation prompt | +| `defaultMode` | `"allowAll"` \| `"askAll"` | Default behavior for scopes not explicitly listed in `allow`/`deny`/`ask`. Defaults to `"allowAll"` | + +### Priority Rules + +When a tool call involves multiple permission scopes, the decision follows this priority: + +1. If any scope matches `deny` → **Deny** +2. If any scope matches `ask` → **Prompt** +3. If all scopes are in `allow` → **Auto-allow** +4. Otherwise → use `defaultMode` + +### Example: Relaxed Mode (default) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +Default behavior: all operations are auto-allowed with no confirmation required. + +### Example: Strict Mode + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +With this configuration: +- Reading/writing inside the workspace and querying Git history → auto-allowed +- All other operations → require user confirmation + +## Persistence + +When you select "Yes, and always allow" in a permission prompt, the corresponding scope is written to the project's `.deepcode/settings.json`: + +- The scope is appended to the `permissions.allow` list +- If the scope was previously in `deny` or `ask`, it is automatically removed +- Duplicate scopes are not written again + +This means subsequent calls involving the same scope will no longer prompt for confirmation. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..50e4149 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,54 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import prettierConfig from "eslint-config-prettier"; + +export default tseslint.config( + // Base recommended rules from ESLint + js.configs.recommended, + // TypeScript recommended rules + ...tseslint.configs.recommended, + // Custom project rules + { + rules: { + // CLI project allows console + "no-console": "off", + // Allow dynamic require for package.json (cli.tsx) + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-require-imports": "off", + // Allow control regex for ANSI stripping (markdown.test.ts) + "no-control-regex": "off", + // Enforce consistent type imports + "@typescript-eslint/consistent-type-imports": "warn", + // Unused vars: allow _-prefixed parameters + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + }, + }, + // React hooks rules + { + plugins: { + "react-hooks": reactHooks, + }, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + }, + // Test files: relaxed rules + { + files: ["src/tests/**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, + // Prettier config: disable conflicting ESLint rules, MUST be last + prettierConfig, +); diff --git a/package-lock.json b/package-lock.json index ad82bd4..dfa3fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,40 +1,307 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.8", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.8", + "version": "0.1.25", "license": "MIT", "dependencies": { - "chalk": "^4.1.2", + "chalk": "^5.6.2", + "ejs": "^5.0.2", + "gradient-string": "^3.0.0", "gray-matter": "^4.0.3", - "ignore": "^5.3.2", - "ink": "^3.2.0", - "openai": "^5.20.0", - "react": "^17.0.2", - "zod": "^3.25.76" + "ignore": "^7.0.5", + "ink": "^7.0.1", + "ink-gradient": "^4.0.0", + "openai": "^6.35.0", + "react": "^19.2.5", + "undici": "^7.25.0", + "zod": "^4.4.3" }, "bin": { - "deepcode": "dist/cli.cjs" + "deepcode": "dist/cli.js" }, "devDependencies": { - "@types/node": "^20.12.7", - "@types/react": "^17.0.2", - "esbuild": "^0.25.12", - "tsx": "^4.19.2", - "typescript": "^5.4.5" + "@eslint/js": "^9.39.4", + "@types/ejs": "^3.1.5", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "esbuild": "^0.28.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "glob": "^13.0.6", + "husky": "^9.1.7", + "lint-staged": "^17.0.4", + "prettier": "^3.8.3", + "tsx": "^4.21.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.2" }, "engines": { - "node": ">=18.17.0" + "node": ">=22" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -49,9 +316,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -66,9 +333,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -83,9 +350,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -100,9 +367,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -117,9 +384,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -134,9 +401,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -151,9 +418,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -168,9 +435,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -185,9 +452,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -202,9 +469,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -219,9 +486,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -236,9 +503,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -253,9 +520,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -270,9 +537,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -287,9 +554,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -304,9 +571,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -321,9 +588,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -338,9 +605,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -355,9 +622,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -372,9 +639,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -389,9 +656,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -406,9 +673,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -423,9 +690,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -440,9 +707,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -457,9 +724,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -473,656 +740,2515 @@ "node": ">=18" } }, - "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "17.0.91", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", - "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "^0.16", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/yoga-layout": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@types/yoga-layout/-/yoga-layout-1.9.2.tgz", - "integrity": "sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw==", - "license": "MIT" - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "color-convert": "^2.0.1" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "license": "MIT", + "@eslint/core": "^0.17.0" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/auto-bind": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", - "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://opencollective.com/eslint" } }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" }, - "node_modules/cli-boxes": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", - "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4" } }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "argparse": "^2.0.1" }, - "engines": { - "node": ">=8" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, "license": "MIT", - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://eslint.org/donate" } }, - "node_modules/code-excerpt": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-3.0.0.tgz", - "integrity": "sha512-VHNTVhd7KsLGOqfX3SyeO8RyYPMp1GJOg194VITk04WMYCv4plV68YWe6TJZxd9MhobjtpMRnVky01gqZsalaw==", - "license": "MIT", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "convert-to-spaces": "^1.0.1" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "color-name": "~1.1.4" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=18.18.0" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/convert-to-spaces": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-1.0.2.tgz", - "integrity": "sha512-cj09EBuObp9gZNQCzc7hByQyrs6jVGE+o9kSJmeUoj+GiPiJvi5LYqEH/Hmme4+MTLHM+Ejtq+FChpjjEnsPdQ==", - "license": "MIT", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": ">= 4" + "node": ">=18.18.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gradient-string": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@types/gradient-string/-/gradient-string-1.1.6.tgz", + "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gradient-string": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/gradient-string/-/gradient-string-3.0.0.tgz", + "integrity": "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "tinygradient": "^1.1.5" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.1.tgz", + "integrity": "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-gradient": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/ink-gradient/-/ink-gradient-4.0.0.tgz", + "integrity": "sha512-Yx227CStr4DaXVkRAQPbBufSUTqe4a4FLOPVoypXZyae5h3A5jWyqZpTmAIbm7iiiqNYCkKIFBUPJM6nSICfxA==", + "license": "MIT", + "dependencies": { + "@types/gradient-string": "^1.1.6", + "gradient-string": "^3.0.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "peerDependencies": { + "ink": ">=6" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lint-staged": { + "version": "17.0.4", + "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", + "integrity": "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.1.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.8.4" + } + }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmmirror.com/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { "node": ">=18" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, "license": "MIT", "dependencies": { - "is-extendable": "^0.1.0" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "ISC", "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=6.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=6" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ink": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-3.2.0.tgz", - "integrity": "sha512-firNp1q3xxTzoItj/eOOSZQnYSlyrWks5llCTVX37nJ59K3eXbQ8PtzCguqo8YI19EELo5QxaKnJd4VxzhU8tg==", - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "auto-bind": "4.0.0", - "chalk": "^4.1.0", - "cli-boxes": "^2.2.0", - "cli-cursor": "^3.1.0", - "cli-truncate": "^2.1.0", - "code-excerpt": "^3.0.0", - "indent-string": "^4.0.0", - "is-ci": "^2.0.0", - "lodash": "^4.17.20", - "patch-console": "^1.0.0", - "react-devtools-core": "^4.19.1", - "react-reconciler": "^0.26.2", - "scheduler": "^0.20.2", - "signal-exit": "^3.0.2", - "slice-ansi": "^3.0.0", - "stack-utils": "^2.0.2", - "string-width": "^4.2.2", - "type-fest": "^0.12.0", - "widest-line": "^3.1.0", - "wrap-ansi": "^6.2.0", - "ws": "^7.5.5", - "yoga-layout-prebuilt": "^1.9.6" + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/ink/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, "engines": { - "node": ">=8.3.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "6.35.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-6.35.0.tgz", + "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" }, "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { - "bufferutil": { + "ws": { "optional": true }, - "utf-8-validate": { + "zod": { "optional": true } } }, - "node_modules/is-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", - "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { - "ci-info": "^2.0.0" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.8.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "p-limit": "^3.0.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "callsites": "^3.0.0" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">=6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "mimic-fn": "^2.1.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=6" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/openai": { - "version": "5.23.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz", - "integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/patch-console": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-1.0.0.tgz", - "integrity": "sha512-nxl9nrnLQmh64iTzMfyylSlRozL7kAXIaxw1fVcLYdyhNkJCRUzirRZTikXGJsg+hc4fqpneTK6iU2H1Q8THSA==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.8.0" } }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=0.10.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/react-devtools-core": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.28.5.tgz", - "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", - "dependencies": { - "shell-quote": "^1.6.1", - "ws": "^7" + "engines": { + "node": ">=6" } }, - "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=0.10.0" } }, "node_modules/react-reconciler": { - "version": "0.26.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.26.2.tgz", - "integrity": "sha512-nK6kgY28HwrMNwDnMui3dvm3rCFjZrcGiuwLc5COUipBK5hWHLOxMJhSnSomirqWwjPBJKV1QcbkI0VJr7Gl1Q==", + "version": "0.33.0", + "resolved": "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" + "scheduler": "^0.27.0" }, "engines": { "node": ">=0.10.0" }, "peerDependencies": { - "react": "^17.0.2" + "react": "^19.2.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", @@ -1131,31 +3257,37 @@ } }, "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", "license": "MIT", "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/section-matter": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "resolved": "https://registry.npmmirror.com/section-matter/-/section-matter-1.0.0.tgz", "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", "license": "MIT", "dependencies": { @@ -1166,47 +3298,70 @@ "node": ">=4" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "shebang-regex": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/sprintf-js": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, "node_modules/stack-utils": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", "dependencies": { @@ -1216,45 +3371,74 @@ "node": ">=10" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/strip-bom-string": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "resolved": "https://registry.npmmirror.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz", "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -1263,9 +3447,89 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsx": { "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", @@ -1285,7 +3549,7 @@ }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" @@ -1302,7 +3566,7 @@ }, "node_modules/tsx/node_modules/@esbuild/android-arm": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" @@ -1319,7 +3583,7 @@ }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" @@ -1336,7 +3600,7 @@ }, "node_modules/tsx/node_modules/@esbuild/android-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz", "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" @@ -1353,7 +3617,7 @@ }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" @@ -1370,7 +3634,7 @@ }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" @@ -1387,7 +3651,7 @@ }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" @@ -1404,7 +3668,7 @@ }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" @@ -1421,7 +3685,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" @@ -1438,7 +3702,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" @@ -1455,7 +3719,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" @@ -1472,7 +3736,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" @@ -1489,7 +3753,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" @@ -1506,7 +3770,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" @@ -1523,7 +3787,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" @@ -1540,7 +3804,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" @@ -1557,7 +3821,7 @@ }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" @@ -1574,7 +3838,7 @@ }, "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" @@ -1591,7 +3855,7 @@ }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" @@ -1608,7 +3872,7 @@ }, "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" @@ -1625,7 +3889,7 @@ }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" @@ -1642,7 +3906,7 @@ }, "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" @@ -1659,7 +3923,7 @@ }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" @@ -1676,7 +3940,7 @@ }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" @@ -1693,7 +3957,7 @@ }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" @@ -1710,7 +3974,7 @@ }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" @@ -1727,7 +3991,7 @@ }, "node_modules/tsx/node_modules/esbuild": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, @@ -1767,22 +4031,38 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz", - "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==", + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1793,46 +4073,150 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.19.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/widest-line": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", - "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", "license": "MIT", "dependencies": { - "string-width": "^4.0.0" + "string-width": "^8.1.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/ws": { "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -1849,26 +4233,70 @@ } } }, - "node_modules/yoga-layout-prebuilt": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/yoga-layout-prebuilt/-/yoga-layout-prebuilt-1.10.0.tgz", - "integrity": "sha512-YnOmtSbv4MTf7RGJMK0FvZ+KD8OEe/J5BNnR0GHhD8J/XcG/Qvxgszm0Un6FTHWW4uHlTgP0IztiXQnGyIR45g==", - "license": "MIT", - "dependencies": { - "@types/yoga-layout": "1.9.2" + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" }, "engines": { - "node": ">=8" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 394de63..71c171c 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,71 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.8", + "version": "0.1.25", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", + "type": "module", "repository": { "type": "git", - "url": "https://github.com/lessweb/deepcode.git" + "url": "https://github.com/lessweb/deepcode-cli.git" }, + "homepage": "https://deepcode.vegamo.cn", "bin": { - "deepcode": "./dist/cli.cjs" + "deepcode": "./dist/cli.js" }, - "main": "./dist/cli.cjs", + "main": "./dist/cli.js", "files": [ - "dist/cli.cjs", - "docs/tools/**", + "dist/cli.js", + "templates/tools/**", + "templates/prompts/**", + "templates/skills/**", "README.md", "LICENSE" ], "engines": { - "node": ">=18.17.0" + "node": ">=22" }, "scripts": { "typecheck": "tsc -p ./ --noEmit", - "bundle": "esbuild ./src/cli.tsx --bundle --platform=node --format=cjs --target=node18 --outfile=dist/cli.cjs --banner:js='#!/usr/bin/env node' --jsx=automatic --jsx-import-source=react --packages=external", - "build": "npm run typecheck && npm run bundle && chmod +x dist/cli.cjs", - "test": "tsx --test src/tests/*.test.ts", + "bundle": "esbuild ./src/cli.tsx --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" --jsx=automatic --jsx-import-source=react --packages=external --log-override:empty-import-meta=silent", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write 'src/**/*.{ts,tsx}'", + "format:check": "prettier --check 'src/**/*.{ts,tsx}'", + "check": "npm run typecheck && npm run lint && npm run format:check", + "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "test": "node src/tests/run-tests.mjs", "test:single": "tsx --test", - "prepack": "npm run build" + "prepack": "npm run build", + "prepare": "husky" }, "dependencies": { - "chalk": "^4.1.2", + "chalk": "^5.6.2", + "ejs": "^5.0.2", + "gradient-string": "^3.0.0", "gray-matter": "^4.0.3", - "ignore": "^5.3.2", - "ink": "^3.2.0", - "openai": "^5.20.0", - "react": "^17.0.2", - "zod": "^3.25.76" + "ignore": "^7.0.5", + "ink": "^7.0.1", + "ink-gradient": "^4.0.0", + "openai": "^6.35.0", + "react": "^19.2.5", + "undici": "^7.25.0", + "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^20.12.7", - "@types/react": "^17.0.2", - "esbuild": "^0.25.12", - "tsx": "^4.19.2", - "typescript": "^5.4.5" + "@eslint/js": "^9.39.4", + "@types/ejs": "^3.1.5", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "esbuild": "^0.28.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "glob": "^13.0.6", + "husky": "^9.1.7", + "lint-staged": "^17.0.4", + "prettier": "^3.8.3", + "tsx": "^4.21.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.2" } } diff --git a/resources/intro2.png b/resources/intro2.png new file mode 100644 index 0000000..942556f Binary files /dev/null and b/resources/intro2.png differ diff --git a/src/AsciiArt.ts b/src/AsciiArt.ts new file mode 100644 index 0000000..0a28273 --- /dev/null +++ b/src/AsciiArt.ts @@ -0,0 +1,8 @@ +export const AsciiLogo = [ + "██████╗ ███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗", + "██╔══██╗██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝", + "██║ ██║█████╗ █████╗ ██████╔╝ ██║ ██║ ██║██║ ██║█████╗", + "██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██║ ██║ ██║██║ ██║██╔══╝", + "██████╔╝███████╗███████╗██║ ╚██████╗╚██████╔╝██████╔╝███████╗", + "╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝", +].join("\n"); diff --git a/src/cli.tsx b/src/cli.tsx index 14ec4d3..c3876ae 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,7 +1,8 @@ import React from "react"; import { render } from "ink"; -import { App } from "./ui/App"; +import { setShellIfWindows } from "./common/shell-utils"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; +import { AppContainer } from "./ui"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -17,14 +18,18 @@ if (args.includes("--help") || args.includes("-h")) { "deepcode - Deep Code CLI", "", "Usage:", - " deepcode Launch the interactive TUI in the current directory", - " deepcode --version Print the version", - " deepcode --help Show this help", + " deepcode Launch the interactive TUI in the current directory", + " deepcode -p Launch with a pre-filled prompt", + " deepcode --prompt Same as -p", + " deepcode --version Print the version", + " deepcode --help Show this help", "", "Configuration:", - " ~/.deepcode/settings.json API key, model, base URL", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", " ~/.agents/skills/*/SKILL.md User-level skills", - " ./.deepcode/skills/*/SKILL.md Project-level skills", + " ./.agents/skills/*/SKILL.md Project-level skills", + " ./.deepcode/skills/*/SKILL.md Legacy project-level skills", "", "Inside the TUI:", " enter Send the prompt", @@ -36,22 +41,36 @@ if (args.includes("--help") || args.includes("-h")) { " ctrl+x Clear pasted images", " esc Interrupt the current model turn", " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", " /exit Quit", - " ctrl+d twice Quit" + " ctrl+d twice Quit", ].join("\n") + "\n" ); process.exit(0); } +function extractInitialPrompt(args: string[]): string | undefined { + const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); + if (promptIndex !== -1 && promptIndex + 1 < args.length) { + return args[promptIndex + 1]; + } + return undefined; +} + +let initialPrompt = extractInitialPrompt(args); const projectRoot = process.cwd(); +configureWindowsShell(); if (!process.stdin.isTTY) { - process.stderr.write( - "deepcode requires an interactive terminal (TTY). " + - "Re-run from a real terminal session.\n" - ); + process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); process.exit(1); } @@ -60,26 +79,61 @@ void main(); async function main(): Promise { const updatePromptResult = await promptForPendingUpdate(packageInfo); - const inkInstance = render(, { - exitOnCtrlC: false - }); + const restartRef: { current: (() => void) | null } = { current: null }; + + function startApp(): void { + let restarting = false; + const appInitialPrompt = initialPrompt; + initialPrompt = undefined; + const inkInstance = render( + restartRef.current?.()} + />, + { exitOnCtrlC: false } + ); + + restartRef.current = () => { + restarting = true; + process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + inkInstance.unmount(); + startApp(); + }; + + inkInstance.waitUntilExit().then(() => { + if (!restarting) { + restartRef.current = null; + process.exit(0); + } + }); + } if (!updatePromptResult.installed) { void checkForNpmUpdate(packageInfo); } - inkInstance.waitUntilExit().then(() => { - process.exit(0); - }); + startApp(); +} + +function configureWindowsShell(): void { + process.env.NoDefaultCurrentDirectoryInExePath = "1"; + try { + setShellIfWindows(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`deepcode: ${message}\n`); + process.exit(1); + } } function readPackageInfo(): PackageInfo { try { - // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require("../package.json") as { name?: unknown; version?: unknown }; return { name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : "" + version: typeof pkg.version === "string" ? pkg.version : "", }; } catch { return { name: "@vegamo/deepcode-cli", version: "" }; diff --git a/src/common/bash-timeout.ts b/src/common/bash-timeout.ts new file mode 100644 index 0000000..0a76d21 --- /dev/null +++ b/src/common/bash-timeout.ts @@ -0,0 +1,12 @@ +export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000; +export const MIN_BASH_TIMEOUT_MS = 60 * 1000; +export const BASH_TIMEOUT_INCREMENT_MS = 5 * 60 * 1000; +export const BASH_TIMEOUT_DECREMENT_MS = 60 * 1000; + +export function clampBashTimeoutMs(timeoutMs: number, minTimeoutMs: number = MIN_BASH_TIMEOUT_MS): number { + if (!Number.isFinite(timeoutMs)) { + return DEFAULT_BASH_TIMEOUT_MS; + } + const minimum = Number.isFinite(minTimeoutMs) ? Math.max(1, Math.round(minTimeoutMs)) : MIN_BASH_TIMEOUT_MS; + return Math.max(minimum, Math.round(timeoutMs)); +} diff --git a/src/common/debug-logger.ts b/src/common/debug-logger.ts new file mode 100644 index 0000000..124049e --- /dev/null +++ b/src/common/debug-logger.ts @@ -0,0 +1,82 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const DEBUG_LOG_FILE = "debug.log"; + +export type OpenAIChatCompletionDebugEntry = { + timestamp: string; + location: string; + requestId?: string; + sessionId?: string; + model?: string; + baseURL?: string; + durationMs?: number; + params?: Record; + request: Record; + response?: unknown; + responseChunks?: unknown[]; + error?: { + name: string; + message: string; + stack?: string; + }; +}; + +export function logOpenAIChatCompletionDebug(entry: OpenAIChatCompletionDebugEntry): void { + try { + const logPath = getDebugLogPath(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${JSON.stringify(toSerializable(entry))}\n`, "utf8"); + } catch { + // Debug logging must never affect CLI behavior. + } +} + +export function getDebugLogPath(): string { + return path.join(os.homedir(), ".deepcode", "logs", DEBUG_LOG_FILE); +} + +export function normalizeDebugError(error: unknown): { name: string; message: string; stack?: string } { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { + name: "UnknownError", + message: String(error), + }; +} + +function toSerializable(value: unknown): unknown { + const seen = new WeakSet(); + + function walk(current: unknown): unknown { + if (typeof current === "bigint") { + return current.toString(); + } + if (current instanceof Error) { + return normalizeDebugError(current); + } + if (!current || typeof current !== "object") { + return current; + } + if (seen.has(current)) { + return "[Circular]"; + } + seen.add(current); + if (Array.isArray(current)) { + return current.map(walk); + } + const result: Record = {}; + for (const [key, val] of Object.entries(current)) { + result[key] = walk(val); + } + return result; + } + + return walk(value); +} diff --git a/src/common/error-logger.ts b/src/common/error-logger.ts new file mode 100644 index 0000000..52d469f --- /dev/null +++ b/src/common/error-logger.ts @@ -0,0 +1,129 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const LOG_DIR = path.join(os.homedir(), ".deepcode", "logs"); +const ERROR_LOG_PATH = path.join(LOG_DIR, "error.log"); + +function ensureLogDir(): void { + if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); + } +} + +/** + * Mask sensitive values (API keys, tokens) that may appear in error messages + * or response bodies. + */ +function maskSensitive(text: string): string { + return ( + text + // Mask Bearer tokens in Authorization headers + .replace(/(Authorization:\s*Bearer\s+)[^\s\r\n]+/gi, "$1***MASKED***") + // Mask "apiKey" or "api_key" values in JSON-like strings + .replace(/((?:api[Kk]ey|api_key|secret)\s*[:=]\s*"?)[^",}\s]+/gi, "$1***MASKED***") + ); +} + +const CONTENT_TRUNCATE_PREVIEW = 100; + +/** + * Truncate a content string for logging: keep a short prefix and append the + * total length so the payload structure is preserved while content bloat is + * avoided. + */ +function truncateContent(value: string): string { + if (value.length <= CONTENT_TRUNCATE_PREVIEW) { + return value; + } + return `${value.slice(0, CONTENT_TRUNCATE_PREVIEW)}...(total ${value.length} chars)`; +} + +/** + * Deep-clone a request payload, only truncating `content` fields whose value + * is a string. Every other field is kept exactly as-is so the logged request + * mirrors the original API payload (no fields added or removed). + */ +function sanitizeRequestPayload(request: Record): Record { + function walk(value: unknown): unknown { + if (!value || typeof value !== "object") { + return value; + } + + if (Array.isArray(value)) { + return value.map(walk); + } + + const record = value as Record; + const result: Record = {}; + + for (const [key, val] of Object.entries(record)) { + if (key === "content" && typeof val === "string") { + result[key] = truncateContent(val); + } else { + result[key] = walk(val); + } + } + + return result; + } + + return walk(request) as Record; +} + +export type ApiErrorLogEntry = { + timestamp: string; + location: string; + requestId: string; + sessionId?: string; + model?: string; + baseURL?: string; + error: { + name: string; + message: string; + stack?: string; + }; + request: Record; + response?: unknown; +}; + +/** + * Write an API error log entry to ~/.deepcode/logs/error.log. + */ +export function logApiError(entry: ApiErrorLogEntry): void { + try { + ensureLogDir(); + + const logLine: Record = { + timestamp: entry.timestamp, + location: entry.location, + requestId: entry.requestId, + sessionId: entry.sessionId, + model: entry.model, + baseURL: entry.baseURL, + error: { + name: entry.error.name, + message: maskSensitive(entry.error.message), + stack: entry.error.stack ? maskSensitive(entry.error.stack) : undefined, + }, + request: sanitizeRequestPayload(entry.request), + }; + + if (entry.response !== undefined) { + logLine.response = typeof entry.response === "string" ? maskSensitive(entry.response) : entry.response; + } + + const newLine = JSON.stringify(logLine) + "\n"; + fs.appendFileSync(ERROR_LOG_PATH, newLine, "utf8"); + + // Keep only the last N entries + const MAX_ENTRIES = 20; + const raw = fs.readFileSync(ERROR_LOG_PATH, "utf8"); + const lines = raw.split("\n").filter((line) => line.trim().length > 0); + if (lines.length > MAX_ENTRIES) { + fs.writeFileSync(ERROR_LOG_PATH, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8"); + } + } catch { + // Silently ignore logging failures to avoid disrupting the main flow + } +} diff --git a/src/common/file-history.ts b/src/common/file-history.ts new file mode 100644 index 0000000..2a41d9a --- /dev/null +++ b/src/common/file-history.ts @@ -0,0 +1,308 @@ +import * as childProcess from "child_process"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; + +const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint"; +const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost"; +const MANIFEST_PATH = ".deepcode-file-history.json"; + +type FileHistoryEntry = { + path: string; + blob: string; + mode: "100644"; +}; + +type FileHistoryManifest = { + version: 1; + files: Record; +}; + +export class GitFileHistory { + constructor( + _projectRoot: string, + private readonly gitDir: string + ) {} + + ensureSession(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + try { + if (!fs.existsSync(this.gitDir)) { + fs.mkdirSync(path.dirname(this.gitDir), { recursive: true }); + this.runGit(["init"]); + } + + const current = this.getCurrentCheckpointHash(sessionId); + if (current) { + return current; + } + + const treeHash = this.createTree(emptyManifest()); + const commitHash = this.createCommit(treeHash, null, "Initial checkpoint"); + this.runGit(["update-ref", branchRef, commitHash]); + return commitHash; + } catch { + return undefined; + } + } + + getCurrentCheckpointHash(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + return undefined; + } + + try { + const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); + return isCommitHash(hash) ? hash : undefined; + } catch { + return undefined; + } + } + + recordCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + const absolutePaths = uniqueAbsolutePaths(filePaths); + if (absolutePaths.length === 0) { + return this.getCurrentCheckpointHash(sessionId); + } + + try { + const parentHash = this.ensureSession(sessionId); + if (!parentHash) { + return undefined; + } + + const manifest = this.readManifest(parentHash); + for (const filePath of absolutePaths) { + const key = this.getFileKey(filePath); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + delete manifest.files[key]; + continue; + } + + manifest.files[key] = { + path: filePath, + blob: this.hashFile(filePath), + mode: "100644", + }; + } + + const treeHash = this.createTree(manifest); + const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`]).trim(); + if (treeHash === parentTreeHash) { + return parentHash; + } + + const commitHash = this.createCommit(treeHash, parentHash, message); + this.runGit(["update-ref", branchRef, commitHash, parentHash]); + return commitHash; + } catch { + return undefined; + } + } + + canRestore(sessionId: string, checkpointHash: string): boolean { + if (!isCommitHash(checkpointHash)) { + return false; + } + if (!this.getSessionBranchRef(sessionId)) { + return false; + } + if (!fs.existsSync(this.gitDir)) { + return false; + } + + try { + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); + this.readManifest(checkpointHash); + return true; + } catch { + return false; + } + } + + restore(sessionId: string, checkpointHash: string): void { + if (!isCommitHash(checkpointHash)) { + throw new Error("Invalid checkpoint hash."); + } + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + throw new Error("File history Git repository was not found for this project."); + } + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); + + const currentHash = this.getCurrentCheckpointHash(sessionId); + const currentManifest = currentHash ? this.readManifest(currentHash) : emptyManifest(); + const targetManifest = this.readManifest(checkpointHash); + + for (const [key, entry] of Object.entries(currentManifest.files)) { + if (!targetManifest.files[key]) { + removeTrackedFile(entry.path); + } + } + + for (const entry of Object.values(targetManifest.files)) { + fs.mkdirSync(path.dirname(entry.path), { recursive: true }); + fs.writeFileSync(entry.path, this.readBlob(entry.blob)); + } + + this.runGit(["update-ref", branchRef, checkpointHash]); + } + + private getSessionBranchRef(sessionId: string): string | null { + if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) { + return null; + } + return `refs/heads/${sessionId}`; + } + + private createCommit(treeHash: string, parentHash: string | null, message: string): string { + const args = ["commit-tree", treeHash]; + if (parentHash) { + args.push("-p", parentHash); + } + args.push("-m", message); + return this.runGit(args, { + env: getFileHistoryGitEnv(), + }).trim(); + } + + private createTree(manifest: FileHistoryManifest): string { + const normalizedManifest = normalizeManifest(manifest); + const manifestBlob = this.hashContent(`${JSON.stringify(normalizedManifest, null, 2)}\n`); + const entries: string[] = [`100644 blob ${manifestBlob}\t${MANIFEST_PATH}\0`]; + + for (const [key, entry] of Object.entries(normalizedManifest.files)) { + entries.push(`${entry.mode} blob ${entry.blob}\t${key}\0`); + } + + return this.runGit(["mktree", "-z"], { input: entries.join("") }).trim(); + } + + private readManifest(commitHash: string): FileHistoryManifest { + const buffer = this.runGitBuffer(["cat-file", "blob", `${commitHash}:${MANIFEST_PATH}`]); + const parsed = JSON.parse(buffer.toString("utf8")) as FileHistoryManifest; + if (!parsed || parsed.version !== 1 || !parsed.files || typeof parsed.files !== "object") { + throw new Error("Invalid file history manifest."); + } + return normalizeManifest(parsed); + } + + private readBlob(blobHash: string): Buffer { + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return this.runGitBuffer(["cat-file", "blob", blobHash]); + } + + private hashFile(filePath: string): string { + const blobHash = this.runGit(["hash-object", "-w", "--", filePath]).trim(); + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return blobHash; + } + + private hashContent(content: string): string { + const blobHash = this.runGit(["hash-object", "-w", "--stdin"], { input: content }).trim(); + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return blobHash; + } + + private getFileKey(filePath: string): string { + const hash = crypto.createHash("sha256").update(filePath).digest("hex"); + return `files-${hash}`; + } + + private runGit(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): string { + return this.spawnGit(args, options, "utf8") as string; + } + + private runGitBuffer(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): Buffer { + return this.spawnGit(args, options, "buffer") as Buffer; + } + + private spawnGit( + args: string[], + options: { input?: string | Buffer; env?: NodeJS.ProcessEnv }, + encoding: BufferEncoding | "buffer" + ): string | Buffer { + const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`, ...args]; + const result = childProcess.spawnSync("git", gitArgs, { + encoding, + input: options.input, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; + const detail = (stderr || stdout || "").trim(); + throw new Error(detail || `git ${args.join(" ")} failed`); + } + return result.stdout ?? (encoding === "buffer" ? Buffer.alloc(0) : ""); + } +} + +function emptyManifest(): FileHistoryManifest { + return { version: 1, files: {} }; +} + +function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest { + const files: Record = {}; + for (const [key, entry] of Object.entries(manifest.files).sort(([left], [right]) => left.localeCompare(right))) { + if (!isValidStoredPath(key) || !entry || entry.mode !== "100644" || !isCommitHash(entry.blob)) { + throw new Error("Invalid file history manifest."); + } + files[key] = { + path: path.resolve(entry.path), + blob: entry.blob, + mode: "100644", + }; + } + return { version: 1, files }; +} + +function uniqueAbsolutePaths(filePaths: string[]): string[] { + return Array.from(new Set(filePaths.map((filePath) => path.resolve(filePath)))); +} + +function isValidStoredPath(value: string): boolean { + return /^files-[0-9a-f]{64}$/.test(value); +} + +function removeTrackedFile(filePath: string): void { + if (!fs.existsSync(filePath)) { + return; + } + const stat = fs.lstatSync(filePath); + if (stat.isDirectory()) { + return; + } + fs.unlinkSync(filePath); +} + +function getFileHistoryGitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + }; +} + +function isCommitHash(value: string): boolean { + return /^[0-9a-f]{40}$/i.test(value); +} diff --git a/src/tools/file-utils.ts b/src/common/file-utils.ts similarity index 92% rename from src/tools/file-utils.ts rename to src/common/file-utils.ts index b5705d9..6656172 100644 --- a/src/tools/file-utils.ts +++ b/src/common/file-utils.ts @@ -35,7 +35,7 @@ export function readTextFileWithMetadata(filePath: string): FileReadMetadata { content: normalizeContent(raw), encoding, lineEndings: detectLineEndings(raw), - timestamp: Math.floor(stat.mtimeMs) + timestamp: Math.floor(stat.mtimeMs), }; } @@ -61,10 +61,7 @@ export function hasFileChangedSinceState(filePath: string, state: FileState): bo return false; } - const isFullRead = - !state.isPartialView && - typeof state.offset === "undefined" && - typeof state.limit === "undefined"; + const isFullRead = !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined"; return !(isFullRead && current.content === state.content); } @@ -86,11 +83,7 @@ export function buildDiffPreview( const newLines = toDiffLines(updated); let prefix = 0; - while ( - prefix < oldLines.length && - prefix < newLines.length && - oldLines[prefix] === newLines[prefix] - ) { + while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { prefix += 1; } @@ -111,7 +104,7 @@ export function buildDiffPreview( const previewLines = [ `--- ${original === null ? "/dev/null" : `a/${filePath}`}`, `+++ b/${filePath}`, - `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@` + `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@`, ]; if (prefix > 0) { diff --git a/src/common/model-capabilities.ts b/src/common/model-capabilities.ts new file mode 100644 index 0000000..4835bfe --- /dev/null +++ b/src/common/model-capabilities.ts @@ -0,0 +1,16 @@ +export const DEEPSEEK_V4_MODELS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]); + +export const NON_MULTIMODAL_MODELS = new Set([ + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-chat", + "deepseek-reasoner", +]); + +export function defaultsToThinkingMode(model: string): boolean { + return DEEPSEEK_V4_MODELS.has(model); +} + +export function supportsMultimodal(model: string): boolean { + return !NON_MULTIMODAL_MODELS.has(model.trim()); +} diff --git a/src/notify.ts b/src/common/notify.ts similarity index 65% rename from src/notify.ts rename to src/common/notify.ts index 8c27583..d1b541b 100644 --- a/src/notify.ts +++ b/src/common/notify.ts @@ -16,21 +16,49 @@ export function formatDurationSeconds(durationMs: number): string { return String(Math.floor(safeMs / 1000)); } +export type NotifyContext = { + status?: string; + failReason?: string; + body?: string; + title?: string; +}; + export function buildNotifyEnv( durationMs: number, - baseEnv: NodeJS.ProcessEnv = process.env + baseEnv: NodeJS.ProcessEnv = process.env, + context: NotifyContext = {} ): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { ...baseEnv, - DURATION: formatDurationSeconds(durationMs) + DURATION: formatDurationSeconds(durationMs), }; + delete env.STATUS; + delete env.FAIL_REASON; + delete env.BODY; + delete env.TITLE; + + if (context.status) { + env.STATUS = context.status; + } + if (context.failReason) { + env.FAIL_REASON = context.failReason; + } + if (context.body) { + env.BODY = context.body; + } + if (context.title) { + env.TITLE = context.title; + } + return env; } export function launchNotifyScript( notifyPath: string | undefined, durationMs: number, workingDirectory?: string, - spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn + spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, + configuredEnv: Record = {}, + context: NotifyContext = {} ): void { const commandPath = notifyPath?.trim(); if (!commandPath) { @@ -40,8 +68,8 @@ export function launchNotifyScript( const options = { cwd: workingDirectory, detached: process.platform !== "win32", - env: buildNotifyEnv(durationMs), - stdio: "ignore" as const + env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context), + stdio: "ignore" as const, }; try { diff --git a/src/common/openai-client.ts b/src/common/openai-client.ts new file mode 100644 index 0000000..c1c3e4d --- /dev/null +++ b/src/common/openai-client.ts @@ -0,0 +1,117 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import OpenAI from "openai"; +import { Agent, fetch as undiciFetch } from "undici"; +import { resolveCurrentSettings } from "../ui/App"; + +// Custom undici Agent with a 180-second keepAlive timeout. The default +// global fetch (undici) only keeps connections alive for 4 seconds, which +// is too short for a CLI where the user may spend 10–30 seconds reading +// output between prompts. By passing a dedicated Agent to undiciFetch we +// keep connections reusable for three minutes after the last request. +const keepAliveAgent = new Agent({ keepAliveTimeout: 180_000 }); + +// Module-level cache for the OpenAI client instance. The client itself is +// a stateless fetch wrapper, so it is safe to share across calls as long as +// the apiKey + baseURL stay the same. Model, thinking-mode and other +// settings are always read fresh from the project / user config files. +let cachedOpenAI: OpenAI | null = null; +let cachedOpenAIKey = ""; + +export function createOpenAIClient(projectRoot: string = process.cwd()): { + client: OpenAI | null; + model: string; + baseURL: string; + thinkingEnabled: boolean; + reasoningEffort: "high" | "max"; + debugLogEnabled: boolean; + notify?: string; + webSearchTool?: string; + env: Record; + machineId?: string; +} { + const settings = resolveCurrentSettings(projectRoot); + if (!settings.apiKey) { + return { + client: null, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + const cacheKey = `${settings.apiKey}::${settings.baseURL}`; + if (cachedOpenAI && cachedOpenAIKey === cacheKey) { + return { + client: cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + cachedOpenAI = new OpenAI({ + apiKey: settings.apiKey, + baseURL: settings.baseURL || undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }), + }); + cachedOpenAIKey = cacheKey; + + // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API + // server while the user is composing their first prompt. Bounded by a + // short timeout so a slow / unreachable API never blocks process exit. + void (async () => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + try { + await cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {}); + } finally { + clearTimeout(timer); + } + })(); + + return { + client: cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; +} + +function getMachineId(): string | undefined { + try { + const idPath = path.join(os.homedir(), ".deepcode", "machine-id"); + if (fs.existsSync(idPath)) { + const raw = fs.readFileSync(idPath, "utf8").trim(); + if (raw) { + return raw; + } + } + const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`; + fs.mkdirSync(path.dirname(idPath), { recursive: true }); + fs.writeFileSync(idPath, generated, "utf8"); + return generated; + } catch { + return undefined; + } +} diff --git a/src/common/openai-thinking.ts b/src/common/openai-thinking.ts new file mode 100644 index 0000000..1585cbd --- /dev/null +++ b/src/common/openai-thinking.ts @@ -0,0 +1,25 @@ +import type { ReasoningEffort } from "../settings"; + +type ThinkingConfig = { + type: "enabled" | "disabled"; +}; + +type ThinkingRequestOptions = { + thinking?: ThinkingConfig; + extra_body?: { + reasoning_effort?: ReasoningEffort; + }; +}; + +export function buildThinkingRequestOptions( + thinkingEnabled: boolean, + _baseURL?: string, + reasoningEffort: ReasoningEffort = "max" +): ThinkingRequestOptions { + const thinking: ThinkingConfig = { type: thinkingEnabled ? "enabled" : "disabled" }; + + return { + thinking, + ...(thinkingEnabled ? { extra_body: { reasoning_effort: reasoningEffort } } : {}), + }; +} diff --git a/src/common/permissions.ts b/src/common/permissions.ts new file mode 100644 index 0000000..564bfeb --- /dev/null +++ b/src/common/permissions.ts @@ -0,0 +1,524 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { DeepcodingSettings, PermissionScope, PermissionSettings } from "../settings"; +import { isAbsoluteFilePath, normalizeFilePath } from "./state"; + +export type BashPermissionScope = Exclude | "unknown"; + +export type PermissionDecision = "allow" | "deny" | "ask"; + +export type UserToolPermission = { + toolCallId: string; + permission: "allow" | "deny"; +}; + +export type MessageToolPermission = { + toolCallId: string; + permission: PermissionDecision; +}; + +export type AskPermissionScope = PermissionScope | "unknown"; + +export type AskPermissionRequest = { + toolCallId: string; + scopes: AskPermissionScope[]; + name: string; + command: string; + description?: string; +}; + +export type PermissionToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type PermissionToolExecution = { + toolCallId: string; + content: string; + result: { + ok: boolean; + name: string; + output?: string; + error?: string; + metadata?: Record; + awaitUserResponse?: boolean; + followUpMessages?: Array<{ role: "system"; content: string; contentParams?: unknown | null }>; + }; +}; + +export type PermissionPlan = { + permissions: MessageToolPermission[]; + askPermissions: AskPermissionRequest[]; +}; + +export type ComputeToolCallPermissionsOptions = { + sessionId: string; + projectRoot: string; + toolCalls: unknown[]; + settings?: Required; + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; +}; + +export function parseToolCallForPermissions(toolCall: unknown): PermissionToolCall | null { + if (!toolCall || typeof toolCall !== "object") { + return null; + } + const record = toolCall as { + id?: unknown; + type?: unknown; + function?: { name?: unknown; arguments?: unknown }; + }; + if (typeof record.id !== "string" || !record.function || typeof record.function !== "object") { + return null; + } + if (typeof record.function.name !== "string") { + return null; + } + return { + id: record.id, + type: "function", + function: { + name: record.function.name, + arguments: typeof record.function.arguments === "string" ? record.function.arguments : "", + }, + }; +} + +export function buildPermissionToolExecution( + toolCall: PermissionToolCall, + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } +): PermissionToolExecution | null { + const permission = resolveToolCallPermission(toolCall.id, options); + if (permission === "allow") { + return null; + } + if (permission === "deny") { + return buildSyntheticToolExecution( + toolCall, + "User denied the required permission for this tool call. Do not try to bypass this decision." + ); + } + return buildSyntheticToolExecution( + toolCall, + "The user has not authorized this tool call yet. Retry only if the permission is still necessary." + ); +} + +export function resolveToolCallPermission( + toolCallId: string, + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } +): PermissionDecision { + const override = options.permissionOverrides?.find((item) => item.toolCallId === toolCallId); + if (override?.permission === "allow" || override?.permission === "deny") { + return override.permission; + } + const messagePermission = options.messagePermissions?.find((item) => item.toolCallId === toolCallId); + if ( + messagePermission?.permission === "allow" || + messagePermission?.permission === "deny" || + messagePermission?.permission === "ask" + ) { + return messagePermission.permission; + } + return "allow"; +} + +export function buildSyntheticToolExecution(toolCall: PermissionToolCall, error: string): PermissionToolExecution { + const result = { + ok: false, + name: toolCall.function.name, + error, + }; + return { + toolCallId: toolCall.id, + content: JSON.stringify(result, null, 2), + result, + }; +} + +export function computeToolCallPermissions(options: ComputeToolCallPermissionsOptions): PermissionPlan { + const permissions: MessageToolPermission[] = []; + const askPermissions: AskPermissionRequest[] = []; + + for (const rawToolCall of options.toolCalls) { + const toolCall = parseToolCallForPermissions(rawToolCall); + if (!toolCall) { + continue; + } + const request = describeToolPermissionRequest({ + sessionId: options.sessionId, + projectRoot: options.projectRoot, + toolCall, + resolveSnippetPath: options.resolveSnippetPath, + }); + const permission = evaluatePermissionScopes(request.scopes, options.settings); + permissions.push({ toolCallId: toolCall.id, permission }); + if (permission === "ask") { + const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings); + askPermissions.push({ + toolCallId: toolCall.id, + scopes: askScopes.length > 0 ? askScopes : request.scopes, + name: request.name, + command: request.command, + description: request.description, + }); + } + } + + return { permissions, askPermissions }; +} + +export function describeToolPermissionRequest(options: { + sessionId: string; + projectRoot: string; + toolCall: PermissionToolCall; + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; +}): AskPermissionRequest { + const name = options.toolCall.function.name; + const args = parseToolArgumentsForPermissions(options.toolCall.function.arguments); + + if (name === "read" || name === "Read") { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("read", filePath), + scopes: filePath ? [isPathInProject(options.projectRoot, filePath) ? "read-in-cwd" : "read-out-cwd"] : [], + }; + } + + if (name === "write" || name === "Write") { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("write", filePath), + scopes: filePath ? [isPathInProject(options.projectRoot, filePath) ? "write-in-cwd" : "write-out-cwd"] : [], + }; + } + + if (name === "edit" || name === "Edit") { + const filePath = resolveEditPermissionPath(options.sessionId, args, options.resolveSnippetPath); + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("edit", filePath), + scopes: filePath + ? [isPathInProject(options.projectRoot, filePath) ? "write-in-cwd" : "write-out-cwd"] + : ["write-out-cwd"], + }; + } + + if (name === "bash" || name === "Bash") { + const command = typeof args.command === "string" ? args.command : "bash"; + const description = typeof args.description === "string" ? args.description : undefined; + return { + toolCallId: options.toolCall.id, + name: "bash", + command, + description, + scopes: parseBashSideEffects(args.sideEffects), + }; + } + + if (name === "WebSearch") { + const query = typeof args.query === "string" ? args.query : "WebSearch"; + return { + toolCallId: options.toolCall.id, + name, + command: query, + scopes: ["network"], + }; + } + + if (name.startsWith("mcp__")) { + return { + toolCallId: options.toolCall.id, + name, + command: name, + scopes: ["mcp"], + }; + } + + return { + toolCallId: options.toolCall.id, + name, + command: name, + scopes: [], + }; +} + +export function evaluatePermissionScopes( + scopes: AskPermissionScope[], + settings: Required = { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll", + } +): PermissionDecision { + if (scopes.includes("unknown")) { + return "ask"; + } + if (scopes.length === 0) { + return "allow"; + } + const permissionScopes = scopes.filter((scope): scope is PermissionScope => scope !== "unknown"); + if (permissionScopes.some((scope) => settings.deny.includes(scope))) { + return "deny"; + } + if (permissionScopes.some((scope) => settings.ask.includes(scope))) { + return "ask"; + } + if (permissionScopes.every((scope) => settings.allow.includes(scope))) { + return "allow"; + } + return settings.defaultMode === "askAll" ? "ask" : "allow"; +} + +export function getPermissionScopesRequiringAsk( + scopes: AskPermissionScope[], + settings: Required = { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll", + } +): AskPermissionScope[] { + const result: AskPermissionScope[] = []; + for (const scope of scopes) { + if (scope === "unknown") { + result.push(scope); + continue; + } + if (settings.deny.includes(scope)) { + continue; + } + if (settings.ask.includes(scope)) { + result.push(scope); + continue; + } + if (settings.allow.includes(scope)) { + continue; + } + if (settings.defaultMode === "askAll") { + result.push(scope); + } + } + return result; +} + +export function parseBashSideEffects(value: unknown): AskPermissionScope[] { + const validScopes = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown", + ]); + if (!Array.isArray(value)) { + return ["unknown"]; + } + const scopes: AskPermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !validScopes.has(item as AskPermissionScope)) { + return ["unknown"]; + } + const scope = item as AskPermissionScope; + if (!scopes.includes(scope)) { + scopes.push(scope); + } + } + if (scopes.includes("unknown")) { + return ["unknown"]; + } + return scopes; +} + +export function parseToolArgumentsForPermissions(rawArguments: string): Record { + if (!rawArguments) { + return {}; + } + try { + const parsed = JSON.parse(rawArguments); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +export function resolveEditPermissionPath( + sessionId: string, + args: Record, + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined +): string { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + if (filePath) { + return filePath; + } + const snippetId = typeof args.snippet_id === "string" ? args.snippet_id : ""; + return snippetId ? (resolveSnippetPath?.(sessionId, snippetId) ?? "") : ""; +} + +export function formatToolPathCommand(toolName: string, filePath: string): string { + return filePath ? `${toolName} ${filePath}` : toolName; +} + +export function isPathInProject(projectRoot: string, filePath: string): boolean { + const normalized = normalizeFilePath(filePath); + const absolutePath = isAbsoluteFilePath(normalized) ? normalized : path.resolve(projectRoot, normalized); + const relative = path.relative(path.resolve(projectRoot), path.resolve(absolutePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function hasUserPermissionReplies(value: { permissions?: unknown; alwaysAllows?: unknown }): boolean { + return Boolean( + (Array.isArray(value.permissions) && value.permissions.length > 0) || + (Array.isArray(value.alwaysAllows) && value.alwaysAllows.length > 0) + ); +} + +export function appendProjectPermissionAllows( + projectRoot: string, + scopes: PermissionScope[] | undefined, + options: { inheritedPermissions?: Required } = {} +): void { + if (!Array.isArray(scopes) || scopes.length === 0) { + return; + } + const validScopes = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", + ]); + const nextScopes = scopes.filter((scope) => validScopes.has(scope)); + if (nextScopes.length === 0) { + return; + } + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + let settings: DeepcodingSettings = {}; + try { + if (fs.existsSync(settingsPath)) { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + settings = parsed as DeepcodingSettings; + } + } + } catch { + settings = {}; + } + + const existingPermissions = settings.permissions; + const permissions: PermissionSettings = existingPermissions + ? { ...existingPermissions } + : options.inheritedPermissions + ? { + allow: [...options.inheritedPermissions.allow], + deny: [...options.inheritedPermissions.deny], + ask: [...options.inheritedPermissions.ask], + defaultMode: options.inheritedPermissions.defaultMode, + } + : {}; + + const currentAllow = Array.isArray(permissions.allow) ? permissions.allow : []; + const allow = [...currentAllow]; + for (const scope of nextScopes) { + if (!allow.includes(scope)) { + allow.push(scope); + } + } + const currentDeny = Array.isArray(permissions.deny) ? permissions.deny : undefined; + const currentAsk = Array.isArray(permissions.ask) ? permissions.ask : undefined; + const deny = currentDeny ? currentDeny.filter((scope) => !nextScopes.includes(scope)) : permissions.deny; + const ask = currentAsk ? currentAsk.filter((scope) => !nextScopes.includes(scope)) : permissions.ask; + const changed = + allow.length !== currentAllow.length || + (currentDeny ? (deny as PermissionScope[]).length !== currentDeny.length : false) || + (currentAsk ? (ask as PermissionScope[]).length !== currentAsk.length : false); + if (existingPermissions && !changed) { + return; + } + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + `${JSON.stringify( + { + ...settings, + permissions: { + ...permissions, + deny, + ask, + allow, + }, + }, + null, + 2 + )}\n`, + "utf8" + ); +} + +export function normalizeAskPermissions(value: unknown): AskPermissionRequest[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const result: AskPermissionRequest[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as Record; + if (typeof record.toolCallId !== "string" || typeof record.name !== "string") { + continue; + } + const scopes = Array.isArray(record.scopes) + ? record.scopes.filter((scope): scope is AskPermissionScope => isAskPermissionScope(scope)) + : []; + result.push({ + toolCallId: record.toolCallId, + scopes, + name: record.name, + command: typeof record.command === "string" ? record.command : record.name, + description: typeof record.description === "string" ? record.description : undefined, + }); + } + return result.length > 0 ? result : undefined; +} + +export function isAskPermissionScope(value: unknown): value is AskPermissionScope { + return ( + value === "read-in-cwd" || + value === "read-out-cwd" || + value === "write-in-cwd" || + value === "write-out-cwd" || + value === "delete-in-cwd" || + value === "delete-out-cwd" || + value === "query-git-log" || + value === "mutate-git-log" || + value === "network" || + value === "mcp" || + value === "unknown" + ); +} diff --git a/src/common/process-tree.ts b/src/common/process-tree.ts new file mode 100644 index 0000000..40a0d1e --- /dev/null +++ b/src/common/process-tree.ts @@ -0,0 +1,61 @@ +import { spawnSync } from "child_process"; + +type TaskkillSpawnSync = ( + command: string, + args: string[], + options: { stdio: "ignore"; windowsHide: true } +) => { status: number | null; error?: Error }; + +export type KillProcessTreeDeps = { + platform?: NodeJS.Platform; + killPid?: (pid: number, signal: NodeJS.Signals) => void; + runTaskkill?: (pid: number) => boolean; + killGroupOnNonWindows?: boolean; +}; + +export function killProcessTree( + pid: number, + signal: NodeJS.Signals = "SIGKILL", + deps: KillProcessTreeDeps = {} +): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + const platform = deps.platform ?? process.platform; + const killPid = deps.killPid ?? ((targetPid, targetSignal) => process.kill(targetPid, targetSignal)); + + if (platform === "win32") { + const runTaskkill = deps.runTaskkill ?? runWindowsTaskkill; + if (runTaskkill(pid)) { + return true; + } + return killDirectProcess(pid, signal, killPid); + } + + if (deps.killGroupOnNonWindows !== false && killDirectProcess(-pid, signal, killPid)) { + return true; + } + return killDirectProcess(pid, signal, killPid); +} + +export function runWindowsTaskkill(pid: number, spawnSyncImpl: TaskkillSpawnSync = spawnSync): boolean { + const result = spawnSyncImpl("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return !result.error && result.status === 0; +} + +function killDirectProcess( + pid: number, + signal: NodeJS.Signals, + killPid: (pid: number, signal: NodeJS.Signals) => void +): boolean { + try { + killPid(pid, signal); + return true; + } catch { + return false; + } +} diff --git a/src/tools/runtime.ts b/src/common/runtime.ts similarity index 72% rename from src/tools/runtime.ts rename to src/common/runtime.ts index acb6bcd..b1195d8 100644 --- a/src/tools/runtime.ts +++ b/src/common/runtime.ts @@ -1,34 +1,35 @@ import { z } from "zod"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import type { ToolExecutionContext, ToolExecutionResult } from "../tools/executor"; -export type ValidationResult = - | { ok: true; input: Record } - | { ok: false; error: string }; +export type ValidationResult = { ok: true; input: Record } | { ok: false; error: string }; export function semanticBoolean(defaultValue = false) { + return z.preprocess((value) => { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + return value; + }, z.boolean().default(defaultValue)); +} + +export function semanticInteger(label: string, options: { min?: number } = {}) { return z.preprocess( (value) => { - if (value === "true") { - return true; - } - if (value === "false") { - return false; + if (typeof value === "string" && value.trim()) { + return Number(value); } return value; }, - z.boolean().default(defaultValue) + z + .number() + .int() + .min(options.min ?? Number.MIN_SAFE_INTEGER, `${label} must be >= ${options.min ?? Number.MIN_SAFE_INTEGER}.`) ); } -export function semanticInteger(label: string, options: { min?: number } = {}) { - return z.preprocess((value) => { - if (typeof value === "string" && value.trim()) { - return Number(value); - } - return value; - }, z.number().int().min(options.min ?? Number.MIN_SAFE_INTEGER, `${label} must be >= ${options.min ?? Number.MIN_SAFE_INTEGER}.`)); -} - export async function executeValidatedTool>>( name: string, schema: TSchema, @@ -46,7 +47,7 @@ export async function executeValidatedTool+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; +let cachedGitBashPath: string | null = null; + +export type ShellKind = "bash" | "zsh" | "unknown"; + +type WindowsGitBashLookup = { + findExecutableCandidates: (executable: string) => string[]; + findGitExecPath: () => string | null; + existsSync: (candidate: string) => boolean; +}; + +export function setShellIfWindows(): void { + if (process.platform !== "win32") { + return; + } + process.env.SHELL = findGitBashPath(); +} + +export function findGitBashPath(): string { + if (cachedGitBashPath) { + return cachedGitBashPath; + } + + const bashPath = resolveWindowsGitBashPath({ + findExecutableCandidates: findAllWindowsExecutableCandidates, + findGitExecPath, + existsSync: fs.existsSync, + }); + if (bashPath) { + cachedGitBashPath = bashPath; + return bashPath; + } + + throw new Error( + "Deep Code on Windows requires Git Bash. Install Git for Windows, or ensure Git's bash.exe is available in PATH." + ); +} + +export function resolveWindowsGitBashPath(lookup: WindowsGitBashLookup): string | null { + return firstExistingWindowsPath( + [ + ...lookup.findExecutableCandidates("bash"), + ...WINDOWS_BASH_LOCATIONS, + ...gitExecPathToBashCandidates(lookup.findGitExecPath()), + ...lookup.findExecutableCandidates("git").flatMap(gitExecutableToBashCandidates), + ], + lookup.existsSync + ); +} + +export function resolveShellPath(): string { + if (process.platform === "win32") { + return findGitBashPath(); + } + + const envShell = process.env.SHELL; + if (envShell && getShellKind(envShell) !== "unknown") { + return envShell; + } + return "/bin/bash"; +} + +export function getShellKind(shellPath: string): ShellKind { + const executable = shellPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? ""; + if (executable === "bash" || executable === "bash.exe") { + return "bash"; + } + if (executable === "zsh" || executable === "zsh.exe") { + return "zsh"; + } + return "unknown"; +} + +export function buildShellInitCommand(shellPath: string): string | null { + switch (getShellKind(shellPath)) { + case "zsh": + return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'].join("; "); + case "bash": + return ['BASHRC="${BASH_ENV:-$HOME/.bashrc}"', 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'].join("; "); + default: + return null; + } +} + +export function buildDisableExtglobCommand(shellPath: string): string | null { + switch (getShellKind(shellPath)) { + case "bash": + return "shopt -u extglob 2>/dev/null || true"; + case "zsh": + return "setopt NO_EXTENDED_GLOB 2>/dev/null || true"; + default: + return null; + } +} + +export function rewriteWindowsNullRedirect(command: string): string { + return command.replace(NUL_REDIRECT_REGEX, "$1/dev/null"); +} + +export function windowsPathToPosixPath(windowsPath: string): string { + if (windowsPath.startsWith("\\\\")) { + return windowsPath.replace(/\\/g, "/"); + } + + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\]/); + if (driveMatch) { + const driveLetter = driveMatch[1].toLowerCase(); + return `/${driveLetter}${windowsPath.slice(2).replace(/\\/g, "/")}`; + } + + return windowsPath.replace(/\\/g, "/"); +} + +export function posixPathToWindowsPath(posixPath: string): string { + if (posixPath.startsWith("//")) { + return posixPath.replace(/\//g, "\\"); + } + + const cygdriveMatch = posixPath.match(/^\/cygdrive\/([A-Za-z])(\/|$)/); + if (cygdriveMatch) { + const driveLetter = cygdriveMatch[1].toUpperCase(); + const rest = posixPath.slice(`/cygdrive/${cygdriveMatch[1]}`.length); + return `${driveLetter}:${(rest || "\\").replace(/\//g, "\\")}`; + } + + const driveMatch = posixPath.match(/^\/([A-Za-z])(\/|$)/); + if (driveMatch) { + const driveLetter = driveMatch[1].toUpperCase(); + const rest = posixPath.slice(2); + return `${driveLetter}:${(rest || "\\").replace(/\//g, "\\")}`; + } + + return posixPath.replace(/\//g, "\\"); +} + +export function toNativeCwd(shellCwd: string): string { + if (process.platform !== "win32") { + return shellCwd; + } + return posixPathToWindowsPath(shellCwd); +} + +export function buildShellEnv(shellPath: string, extraEnv: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + ...extraEnv, + SHELL: shellPath, + GIT_EDITOR: "true", + }; + + if (process.platform === "win32") { + const tmpdir = windowsPathToPosixPath(os.tmpdir()); + env.TMPDIR = tmpdir; + env.TMPPREFIX = path.posix.join(tmpdir, "zsh"); + } + + return env; +} + +function findAllWindowsExecutableCandidates(executable: string): string[] { + const extraCandidates = + executable === "git" ? WINDOWS_GIT_LOCATIONS : executable === "bash" ? WINDOWS_BASH_LOCATIONS : []; + + try { + const output = execFileSync("where.exe", [executable], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + let whereResults = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (executable === "bash") { + // Skip WSL's deprecated bash.exe launcher (C:\Windows\System32\bash.exe). + // It would start commands inside the Linux distro instead of the Windows host, + // breaking all path translations and tool invocations. + whereResults = whereResults.filter((candidate) => !/system32[\\/]bash\.exe$/i.test(candidate)); + } + return filterWindowsExecutableCandidates([...whereResults, ...extraCandidates]); + } catch { + return filterWindowsExecutableCandidates(extraCandidates); + } +} + +function findGitExecPath(): string | null { + try { + const output = execFileSync("git", ["--exec-path"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }).trim(); + return output || null; + } catch { + return null; + } +} + +function gitExecPathToBashCandidates(execPath: string | null): string[] { + if (!execPath) { + return []; + } + + const normalized = execPath.replace(/\//g, "\\"); + return [ + pathWin32.join(normalized, "..", "..", "..", "bin", "bash.exe"), + pathWin32.join(normalized, "..", "..", "bin", "bash.exe"), + ]; +} + +function gitExecutableToBashCandidates(gitPath: string): string[] { + return [pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"), pathWin32.join(gitPath, "..", "bin", "bash.exe")]; +} + +function firstExistingWindowsPath(candidates: string[], existsSync: (candidate: string) => boolean): string | null { + const seen = new Set(); + for (const candidate of candidates) { + const normalized = pathWin32.resolve(candidate); + const key = normalized.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + if (getShellKind(normalized) === "bash" && existsSync(normalized)) { + return normalized; + } + } + return null; +} + +function filterWindowsExecutableCandidates(candidates: string[]): string[] { + const cwd = process.cwd().toLowerCase(); + const seen = new Set(); + const results: string[] = []; + + for (const candidate of candidates) { + const normalized = path.resolve(candidate).toLowerCase(); + const candidateDir = path.dirname(normalized).toLowerCase(); + if (candidateDir === cwd || normalized.startsWith(`${cwd}${path.sep}`)) { + continue; + } + if (!seen.has(normalized) && fs.existsSync(candidate)) { + seen.add(normalized); + results.push(candidate); + } + } + + return results; +} diff --git a/src/tools/state.ts b/src/common/state.ts similarity index 51% rename from src/tools/state.ts rename to src/common/state.ts index 01f8c24..add27f3 100644 --- a/src/tools/state.ts +++ b/src/common/state.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import { posixPathToWindowsPath } from "./shell-utils"; export type FileLineEnding = "LF" | "CRLF"; @@ -6,6 +7,7 @@ export type FileState = { filePath: string; content: string; timestamp: number; + version?: number; offset?: number; limit?: number; isPartialView?: boolean; @@ -19,17 +21,50 @@ export type FileSnippet = { startLine: number; endLine: number; preview: string; + fileVersion: number; }; const fileStatesBySession = new Map>(); const snippetsBySession = new Map>(); const snippetCountersBySession = new Map(); +const fileVersionsBySession = new Map>(); -export function normalizeFilePath(filePath: string): string { - return path.normalize(filePath); +export function normalizeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { + const nativePath = normalizeNativeFilePath(filePath, platform); + return platform === "win32" ? path.win32.normalize(nativePath) : path.normalize(nativePath); } -export function recordFileState(sessionId: string, state: FileState): void { +export function normalizeNativeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { + if (platform !== "win32") { + return filePath; + } + + if (isGitBashAbsolutePath(filePath)) { + return posixPathToWindowsPath(filePath); + } + + return filePath; +} + +export function isAbsoluteFilePath(filePath: string, platform: NodeJS.Platform = process.platform): boolean { + const nativePath = normalizeNativeFilePath(filePath, platform); + if (platform !== "win32") { + return path.isAbsolute(nativePath); + } + + const normalized = path.win32.normalize(nativePath); + return path.win32.isAbsolute(normalized) && (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)); +} + +function isGitBashAbsolutePath(filePath: string): boolean { + return /^\/[A-Za-z](?:\/|$)/.test(filePath) || /^\/cygdrive\/[A-Za-z](?:\/|$)/.test(filePath); +} + +export function recordFileState( + sessionId: string, + state: FileState, + options: { incrementVersion?: boolean } = {} +): void { if (!sessionId || !state.filePath) { return; } @@ -41,9 +76,13 @@ export function recordFileState(sessionId: string, state: FileState): void { } const normalizedPath = normalizeFilePath(state.filePath); + const currentVersion = getFileVersion(sessionId, normalizedPath); + const nextVersion = options.incrementVersion ? currentVersion + 1 : currentVersion; + setFileVersion(sessionId, normalizedPath, nextVersion); sessionState.set(normalizedPath, { ...state, - filePath: normalizedPath + filePath: normalizedPath, + version: nextVersion, }); } @@ -64,7 +103,7 @@ export function markFileRead( limit: state?.limit, isPartialView: state?.isPartialView, encoding: state?.encoding, - lineEndings: state?.lineEndings + lineEndings: state?.lineEndings, }); } @@ -80,12 +119,25 @@ export function wasFileRead(sessionId: string, filePath: string): boolean { return getFileState(sessionId, filePath) !== null; } +export function getFileVersion(sessionId: string, filePath: string): number { + if (!sessionId || !filePath) { + return 0; + } + return fileVersionsBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? 0; +} + +function setFileVersion(sessionId: string, filePath: string, version: number): void { + let sessionVersions = fileVersionsBySession.get(sessionId); + if (!sessionVersions) { + sessionVersions = new Map(); + fileVersionsBySession.set(sessionId, sessionVersions); + } + sessionVersions.set(normalizeFilePath(filePath), version); +} + export function isFullFileView(state: FileState | null): boolean { return Boolean( - state && - !state.isPartialView && - typeof state.offset === "undefined" && - typeof state.limit === "undefined" + state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined" ); } @@ -108,7 +160,8 @@ export function createSnippet( filePath: normalizeFilePath(filePath), startLine, endLine, - preview + preview, + fileVersion: getFileVersion(sessionId, filePath), }; let snippets = snippetsBySession.get(sessionId); @@ -126,3 +179,7 @@ export function getSnippet(sessionId: string, snippetId: string): FileSnippet | } return snippetsBySession.get(sessionId)?.get(snippetId) ?? null; } + +export function hasSnippetOutdatedFileVersion(sessionId: string, snippet: FileSnippet): boolean { + return getFileVersion(sessionId, snippet.filePath) > snippet.fileVersion; +} diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts new file mode 100644 index 0000000..26a7a32 --- /dev/null +++ b/src/mcp/mcp-client.ts @@ -0,0 +1,446 @@ +import { spawn, type ChildProcess } from "child_process"; +import { createInterface, type Interface } from "readline"; +import * as path from "path"; +import { killProcessTree } from "../common/process-tree"; + +type JsonRpcRequest = { + jsonrpc: "2.0"; + id: number; + method: string; + params?: Record; +}; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +type JsonRpcNotification = { + jsonrpc: "2.0"; + method: string; + params?: Record; +}; + +export type McpToolDefinition = { + name: string; + description?: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; +}; + +type ListToolsResult = { + tools: McpToolDefinition[]; + nextCursor?: string; +}; + +type CallToolResult = { + content: Array<{ type: string; text?: string }>; + isError?: boolean; +}; + +export type McpPromptArgument = { + name: string; + description?: string; + required?: boolean; +}; + +export type McpPromptDefinition = { + name: string; + description?: string; + arguments?: McpPromptArgument[]; +}; + +type ListPromptsResult = { + prompts: McpPromptDefinition[]; + nextCursor?: string; +}; + +export type McpPromptMessage = { + role: "user" | "assistant"; + content: { type: string; text?: string }; +}; + +type GetPromptResult = { + description?: string; + messages: McpPromptMessage[]; +}; + +export type McpResourceDefinition = { + uri: string; + name: string; + description?: string; + mimeType?: string; +}; + +type ListResourcesResult = { + resources: McpResourceDefinition[]; + nextCursor?: string; +}; + +export type McpResourceContent = { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +}; + +type ReadResourceResult = { + contents: McpResourceContent[]; +}; + +export type McpNotificationHandler = (method: string, params?: Record) => void; + +export type McpSpawnSpec = { + command: string; + args: string[]; + shell: boolean; + windowsHide?: boolean; +}; + +export class McpClient { + private process: ChildProcess | null = null; + private reader: Interface | null = null; + private nextId = 1; + private pendingRequests = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout } + >(); + private stderrBuffer = ""; + private notificationHandler: McpNotificationHandler | null = null; + private disconnectHandler: ((reason: string) => void) | null = null; + private intentionallyDisconnected = false; + + constructor( + private readonly serverName: string, + private readonly command: string, + private readonly args: string[] = [], + private readonly env?: Record, + onNotification?: McpNotificationHandler, + onDisconnect?: (reason: string) => void + ) { + this.notificationHandler = onNotification ?? null; + this.disconnectHandler = onDisconnect ?? null; + } + + async connect(timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + this.intentionallyDisconnected = false; + const childEnv = { + ...process.env, + ...this.env, + }; + const args = this.withNpxYesArg(this.command, this.args); + const spawnSpec = createMcpSpawnSpec(this.command, args); + + this.process = spawn(spawnSpec.command, spawnSpec.args, { + stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + shell: spawnSpec.shell, + windowsHide: spawnSpec.windowsHide, + }); + + let resolved = false; + const safeReject = (err: Error) => { + if (!resolved) { + resolved = true; + reject(err); + } + }; + + this.process.on("error", (err) => { + safeReject( + this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`) + ); + }); + + this.process.on("close", (code) => { + const reason = `MCP server "${this.serverName}" exited with code ${code}`; + const error = this.withStderr(reason); + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + this.reader?.close(); + this.reader = null; + this.process = null; + if (!this.intentionallyDisconnected && this.disconnectHandler) { + this.disconnectHandler(reason); + } + safeReject(error); + }); + + if (this.process.stderr) { + this.process.stderr.on("data", (data: Buffer) => { + this.appendStderr(data.toString("utf8")); + }); + } + + this.reader = createInterface({ input: this.process.stdout! }); + this.reader.on("line", (line: string) => { + this.handleLine(line); + }); + + // Send initialize request (MCP protocol handshake) + this.sendRequest( + "initialize", + { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "deepcode-cli", version: "0.1.0" }, + }, + timeoutMs + ) + .then((result) => { + // Validate protocol version from server response (per MCP spec §4.2.1.2) + const initResult = result as { protocolVersion?: string } | undefined; + const serverVersion = initResult?.protocolVersion; + if (serverVersion && serverVersion !== "2025-03-26" && serverVersion !== "2024-11-05") { + reject( + new Error( + `Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` + + `Client supports 2025-03-26 and 2024-11-05.` + ) + ); + return; + } + // Send initialized notification + this.sendNotification("notifications/initialized"); + resolve(); + }) + .catch(reject); + }); + } + + async listTools(timeoutMs: number): Promise { + const tools: McpToolDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("tools/list", params, timeoutMs)) as ListToolsResult; + tools.push(...(result.tools ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return tools; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many tools/list pages`); + } + + async callTool(name: string, args: Record, timeoutMs = 60_000): Promise { + return (await this.sendRequest("tools/call", { name, arguments: args }, timeoutMs)) as CallToolResult; + } + + async listPrompts(timeoutMs: number): Promise { + const prompts: McpPromptDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("prompts/list", params, timeoutMs)) as ListPromptsResult; + prompts.push(...(result.prompts ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return prompts; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many prompts/list pages`); + } + + async getPrompt(name: string, args: Record, timeoutMs = 30_000): Promise { + return (await this.sendRequest("prompts/get", { name, arguments: args }, timeoutMs)) as GetPromptResult; + } + + async listResources(timeoutMs: number): Promise { + const resources: McpResourceDefinition[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 100; page++) { + const params = cursor ? { cursor } : {}; + const result = (await this.sendRequest("resources/list", params, timeoutMs)) as ListResourcesResult; + resources.push(...(result.resources ?? [])); + cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined; + if (!cursor) { + return resources; + } + } + + throw this.withStderr(`MCP server "${this.serverName}" returned too many resources/list pages`); + } + + async readResource(uri: string, timeoutMs = 30_000): Promise { + return (await this.sendRequest("resources/read", { uri }, timeoutMs)) as ReadResourceResult; + } + + disconnect(): void { + this.intentionallyDisconnected = true; + if (this.reader) { + this.reader.close(); + this.reader = null; + } + if (this.process) { + if (typeof this.process.pid === "number") { + killProcessTree(this.process.pid, "SIGTERM", { killGroupOnNonWindows: false }); + } else { + this.process.kill(); + } + this.process = null; + } + } + + isConnected(): boolean { + return this.process !== null && this.process.exitCode === null; + } + + private sendRequest(method: string, params: Record, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject( + this.withStderr( + `Timed out after ${timeoutMs}ms waiting for MCP server "${this.serverName}" to respond to ${method}` + ) + ); + }, timeoutMs); + this.pendingRequests.set(id, { resolve, reject, timer }); + this.writeLine(JSON.stringify(request)); + }); + } + + private sendNotification(method: string, params?: Record): void { + const notification = { + jsonrpc: "2.0" as const, + method, + params, + }; + this.writeLine(JSON.stringify(notification)); + } + + private writeLine(data: string): void { + if (this.process?.stdin) { + this.process.stdin.write(data + "\n"); + } + } + + private handleLine(line: string): void { + try { + const parsed: unknown = JSON.parse(line); + + // Handle JSON-RPC batch (array of requests/notifications/responses) + // Per MCP 2025-03-26 §4.1.1.3: implementations MUST support receiving batches. + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item === "object") { + this.handleSingleMessage(item); + } + } + return; + } + + // Handle single message + if (parsed && typeof parsed === "object") { + this.handleSingleMessage(parsed); + } + } catch { + // Ignore unparseable lines + } + } + + private handleSingleMessage(msg: object): void { + // Handle notifications (no id field — server-initiated) + if (!("id" in msg)) { + const notification = msg as unknown as JsonRpcNotification; + if (this.notificationHandler && typeof notification.method === "string") { + try { + this.notificationHandler(notification.method, notification.params); + } catch { + // Swallow handler errors to avoid crashing the reader loop + } + } + return; + } + + // Handle responses to our requests + const message = msg as unknown as JsonRpcResponse; + if (message.id !== undefined && this.pendingRequests.has(message.id)) { + const pending = this.pendingRequests.get(message.id)!; + this.pendingRequests.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } + } + + private withNpxYesArg(command: string, args: string[]): string[] { + const executable = path + .basename(command) + .toLowerCase() + .replace(/\.cmd$/, ""); + if (executable !== "npx") { + return args; + } + if (args.includes("-y") || args.includes("--yes")) { + return args; + } + return ["-y", ...args]; + } + + private appendStderr(text: string): void { + this.stderrBuffer = `${this.stderrBuffer}${text}`; + if (this.stderrBuffer.length > 4000) { + this.stderrBuffer = this.stderrBuffer.slice(-4000); + } + } + + private withStderr(message: string): Error { + const stderr = this.stderrBuffer.trim(); + return new Error(stderr ? `${message}. stderr: ${stderr}` : message); + } +} + +export function createMcpSpawnSpec( + command: string, + args: string[], + platform: NodeJS.Platform = process.platform +): McpSpawnSpec { + if (platform === "win32") { + return { + // On Windows, shell: true lets cmd.exe resolve the command via PATHEXT + // (npx -> npx.cmd, etc.). Pass one quoted command line with no spawn + // args to avoid Node 24 DEP0190. + command: [command, ...args].map(quoteWindowsShellArg).join(" "), + args: [], + shell: true, + windowsHide: true, + }; + } + + return { + command, + args, + shell: false, + }; +} + +function quoteWindowsShellArg(arg: string): string { + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`; +} diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts new file mode 100644 index 0000000..fe8066b --- /dev/null +++ b/src/mcp/mcp-manager.ts @@ -0,0 +1,453 @@ +import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client"; +import type { McpServerConfig } from "../settings"; + +const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT + ? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10) + : 30_000; +const MCP_CALL_TOOL_TIMEOUT_MS = 60_000; + +type McpToolEntry = { + serverName: string; + originalName: string; + namespacedName: string; + definition: McpToolDefinition; + client: McpClient; +}; + +export type McpServerStatus = { + name: string; + status: "starting" | "ready" | "failed" | "reconnecting"; + connected: boolean; + error?: string; + toolCount: number; + tools: string[]; + promptCount: number; + prompts: string[]; + resourceCount: number; + resources: string[]; +}; + +export class McpManager { + private clients: McpClient[] = []; + private tools: McpToolEntry[] = []; + private prompts: Array<{ + serverName: string; + namespacedName: string; + definition: McpPromptDefinition; + client: McpClient; + }> = []; + private resources: Array<{ + serverName: string; + namespacedName: string; + definition: McpResourceDefinition; + client: McpClient; + }> = []; + private initialized = false; + private disposed = false; + private configuredServerNames: string[] = []; + private serverStatuses: McpServerStatus[] = []; + private onToolsListChanged: (() => void) | null = null; + private onStatusChanged: (() => void) | null = null; + private serverConfigs: Record = {}; + + prepare(servers?: Record): void { + if (!servers || Object.keys(servers).length === 0) return; + this.disposed = false; + + for (const name of Object.keys(servers)) { + if (!this.configuredServerNames.includes(name)) { + this.configuredServerNames.push(name); + } + if (this.serverStatuses.some((status) => status.name === name)) { + continue; + } + this.setStatus({ + name, + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + } + } + + async initialize(servers?: Record): Promise { + if (this.initialized || this.disposed) return; + this.initialized = true; + + if (!servers || Object.keys(servers).length === 0) return; + + this.serverConfigs = servers; + this.prepare(servers); + + for (const [name, config] of Object.entries(servers)) { + if (this.disposed) break; + await this.connectServer(name, config); + } + } + + async reconnect(name: string, config?: McpServerConfig): Promise { + if (this.disposed) return; + const effectiveConfig = config ?? this.serverConfigs[name]; + if (!effectiveConfig) return; + if (config) { + this.serverConfigs[name] = config; + } + + this.setStatus({ + name, + status: "reconnecting", + connected: false, + error: "Reconnecting...", + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + + await this.connectServer(name, effectiveConfig); + } + + private async connectServer(name: string, config: McpServerConfig): Promise { + if (this.disposed) return; + + // Clean up stale entries from previous connection attempts + this.clients = this.clients.filter((c) => c.isConnected()); + this.tools = this.tools.filter((t) => t.serverName !== name); + this.prompts = this.prompts.filter((p) => p.serverName !== name); + this.resources = this.resources.filter((r) => r.serverName !== name); + + let client: McpClient | null = null; + try { + client = new McpClient( + name, + config.command, + config.args ?? [], + config.env, + (method) => { + if (method === "notifications/tools/list_changed") { + this.refreshServerTools(name, client!).catch(() => {}); + } + }, + (reason) => { + if (!this.disposed && this.serverConfigs[name]) { + this.onServerCrash(name, reason); + } + } + ); + await client.connect(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) { + client.disconnect(); + return; + } + this.clients.push(client); + + const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) return; + const toolNamespacedNames: string[] = []; + for (const tool of serverTools) { + const namespacedName = `mcp__${name}__${tool.name}`; + this.tools.push({ + serverName: name, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + toolNamespacedNames.push(namespacedName); + } + + let serverPrompts: McpPromptDefinition[] = []; + try { + serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); + } catch { + // server may not support prompts + } + if (this.disposed) return; + const promptNamespacedNames: string[] = []; + for (const prompt of serverPrompts) { + const namespacedName = `mcp__${name}__${prompt.name}`; + this.prompts.push({ + serverName: name, + namespacedName, + definition: prompt, + client, + }); + promptNamespacedNames.push(namespacedName); + } + + let serverResources: McpResourceDefinition[] = []; + try { + serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); + } catch { + // server may not support resources + } + if (this.disposed) return; + const resourceNamespacedNames: string[] = []; + for (const resource of serverResources) { + const namespacedName = `mcp__${name}__${resource.name}`; + this.resources.push({ + serverName: name, + namespacedName, + definition: resource, + client, + }); + resourceNamespacedNames.push(namespacedName); + } + + this.setStatus({ + name, + status: "ready", + connected: true, + toolCount: serverTools.length, + tools: toolNamespacedNames, + promptCount: serverPrompts.length, + prompts: promptNamespacedNames, + resourceCount: serverResources.length, + resources: resourceNamespacedNames, + }); + } catch (err) { + client?.disconnect(); + const message = err instanceof Error ? err.message : String(err); + this.setStatus({ + name, + status: "failed", + connected: false, + error: message, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + } + } + + private onServerCrash(name: string, reason: string): void { + if (this.disposed) return; + this.clients = this.clients.filter((c) => c.isConnected()); + this.tools = this.tools.filter((t) => t.serverName !== name); + this.prompts = this.prompts.filter((p) => p.serverName !== name); + this.resources = this.resources.filter((r) => r.serverName !== name); + this.onToolsListChanged?.(); + this.setStatus({ + name, + status: "failed", + connected: false, + error: reason, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + } + + getStatus(): McpServerStatus[] { + const result = [...this.serverStatuses]; + const knownNames = new Set(result.map((s) => s.name)); + for (const name of this.configuredServerNames) { + if (!knownNames.has(name)) { + result.push({ + name, + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + } + } + return result; + } + + getMcpToolDefinitions(): Array<{ + type: "function"; + function: { + name: string; + description: string; + parameters: { + type: "object"; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; + }; + }> { + return this.tools.map((t) => ({ + type: "function" as const, + function: { + name: t.namespacedName, + description: t.definition.description ?? `${t.serverName}: ${t.originalName}`, + parameters: { + type: "object" as const, + properties: t.definition.inputSchema.properties, + required: t.definition.inputSchema.required, + ...(t.definition.inputSchema.additionalProperties !== undefined + ? { additionalProperties: t.definition.inputSchema.additionalProperties } + : {}), + }, + }, + })); + } + + isMcpTool(name: string): boolean { + return name.startsWith("mcp__"); + } + + async executeMcpTool( + name: string, + args: Record, + timeoutMs = MCP_CALL_TOOL_TIMEOUT_MS + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const tool = this.tools.find((t) => t.namespacedName === name); + if (!tool) { + return { ok: false, name, error: `Unknown MCP tool: ${name}` }; + } + + try { + const result = await tool.client.callTool(tool.originalName, args, timeoutMs); + const text = result.content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text) + .join("\n"); + return { + ok: !result.isError, + name, + output: text || JSON.stringify(result.content), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async getMcpPrompt( + name: string, + args: Record + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const prompt = this.prompts.find((p) => p.namespacedName === name); + if (!prompt) { + return { ok: false, name, error: `Unknown MCP prompt: ${name}` }; + } + + try { + const result = await prompt.client.getPrompt(prompt.definition.name, args); + const text = result.messages + .filter((m) => m.content.type === "text" && m.content.text) + .map((m) => `[${m.role}] ${m.content.text}`) + .join("\n"); + return { + ok: true, + name, + output: text || JSON.stringify(result), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async readMcpResource( + name: string, + uri: string + ): Promise<{ ok: boolean; name: string; output?: string; error?: string }> { + const resource = this.resources.find((r) => r.namespacedName === name); + if (!resource) { + return { ok: false, name, error: `Unknown MCP resource: ${name}` }; + } + + try { + const result = await resource.client.readResource(uri); + const text = result.contents + .filter((c) => c.text) + .map((c) => c.text) + .join("\n"); + return { + ok: true, + name, + output: text || JSON.stringify(result.contents), + }; + } catch (err) { + return { + ok: false, + name, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + disconnect(): void { + this.disposed = true; + for (const client of this.clients) { + client.disconnect(); + } + this.clients = []; + this.tools = []; + this.prompts = []; + this.resources = []; + this.serverStatuses = []; + this.configuredServerNames = []; + this.serverConfigs = {}; + this.initialized = false; + } + + private async refreshServerTools(serverName: string, client: McpClient): Promise { + const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); + this.tools = this.tools.filter((t) => t.serverName !== serverName); + const toolNamespacedNames: string[] = []; + for (const tool of serverTools) { + const namespacedName = `mcp__${serverName}__${tool.name}`; + this.tools.push({ + serverName, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + toolNamespacedNames.push(namespacedName); + } + const existing = this.serverStatuses.find((s) => s.name === serverName); + if (existing) { + existing.toolCount = serverTools.length; + existing.tools = toolNamespacedNames; + } + this.onToolsListChanged?.(); + } + + setOnToolsListChanged(handler: () => void): void { + this.onToolsListChanged = handler; + } + + setOnStatusChanged(handler: () => void): void { + this.onStatusChanged = handler; + } + + private setStatus(status: McpServerStatus): void { + if (this.disposed) return; + const index = this.serverStatuses.findIndex((s) => s.name === status.name); + if (index === -1) { + this.serverStatuses.push(status); + } else { + this.serverStatuses[index] = status; + } + this.onStatusChanged?.(); + } +} diff --git a/src/model-capabilities.ts b/src/model-capabilities.ts deleted file mode 100644 index fe8cd4a..0000000 --- a/src/model-capabilities.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const DEEPSEEK_V4_MODELS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]); - -export function defaultsToThinkingMode(model: string): boolean { - return DEEPSEEK_V4_MODELS.has(model); -} diff --git a/src/openai-thinking.ts b/src/openai-thinking.ts deleted file mode 100644 index 935f1aa..0000000 --- a/src/openai-thinking.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ReasoningEffort } from "./settings"; - -type ThinkingConfig = { - type: "enabled"; -}; - -type ThinkingRequestOptions = { - thinking?: ThinkingConfig; - extra_body?: { - thinking?: ThinkingConfig; - reasoning_effort?: ReasoningEffort; - }; -}; - -export function buildThinkingRequestOptions( - thinkingEnabled: boolean, - baseURL?: string, - reasoningEffort: ReasoningEffort = "max" -): ThinkingRequestOptions { - if (!thinkingEnabled) { - return {}; - } - - const thinking: ThinkingConfig = { type: "enabled" }; - const normalizedBaseURL = baseURL?.toLowerCase() ?? ""; - - if (normalizedBaseURL.includes(".volces.com")) { - return { - thinking, - extra_body: { reasoning_effort: reasoningEffort } - }; - } - - return { - extra_body: { - thinking, - reasoning_effort: reasoningEffort - } - }; -} diff --git a/src/prompt.ts b/src/prompt.ts index 26a8366..ba9bf23 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,163 +1,12 @@ -import { execSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { fileURLToPath } from "url"; +import ejs from "ejs"; import type { SessionMessage } from "./session"; - -export const AGENT_DRIFT_GUARD_SKILL = ` ---- -name: agent-drift-guard -description: Detect and correct execution drift while working on user requests. Use when you are actively implementing, debugging, reviewing, or investigating and there is a risk of wandering beyond the user's goal, adding unrequested work, touching live systems, over-exploring, or ignoring repeated user boundary corrections. Especially useful during multi-step coding tasks, production-adjacent requests, ambiguous scopes, and anytime you should self-check whether it is still solving the requested problem. ---- - -# Agent Drift Guard - -Keep execution tightly aligned with the user's actual request. - -## Quick Start - -Run this mental check before substantial work and again whenever the plan expands: - -1. State the user's requested outcome in one sentence. -2. List explicit non-goals or boundaries the user has set. -3. Ask whether the next action directly advances the requested outcome. -4. If not, either cut it or pause to confirm. - -## Drift Signals - -Treat these as warning signs that execution may be drifting: - -- Exploring broadly before opening the most relevant file, command, or artifact. -- Solving adjacent operational issues when the user asked only for code changes. -- Adding extra safeguards, scripts, docs, refactors, or cleanup that the user did not ask for. -- Reframing the task around what seems "better" instead of what was requested. -- Continuing with a broader plan after the user narrows the scope. -- Repeating searches or tool calls without increasing certainty. -- Mixing diagnosis, remediation, and feature work when the user asked for only one of them. -- Touching production-like state, external systems, or live data without explicit permission. - -## Severity Levels - -### Level 1: Mild Drift - -Examples: -- One or two extra exploratory commands. -- Considering a broader solution but not acting on it yet. -- Briefly over-explaining instead of moving the task forward. - -Response: -- Auto-correct silently. -- Narrow to the smallest next action. -- Do not interrupt the user. - -### Level 2: Material Drift - -Examples: -- Planning additional deliverables not requested. -- Writing helper scripts, migrations, docs, or tests outside the asked scope. -- Expanding from code changes into operational fixes. -- Continuing after the user has already corrected the scope once. - -Response: -- Stop and realign internally first. -- If the broader action is avoidable, drop it and continue on scope. -- If the broader action has non-obvious tradeoffs, ask a brief confirmation question. - -### Level 3: Boundary or Risk Violation - -Examples: -- Modifying live systems, production data, external services, or user-owned state without being asked. -- Taking destructive or hard-to-reverse actions outside the requested scope. -- Ignoring repeated user instructions about what not to do. - -Response: -- Pause before acting. -- Surface the exact boundary and ask for confirmation. -- Offer the smallest on-scope option first. - -## Self-Check Loop - -Use this loop during execution: - -### Before the first meaningful action - -Write down mentally: -- Requested outcome -- Allowed scope -- Forbidden scope -- Smallest useful next step - -### After each non-trivial step - -Ask: -- Did this step directly help deliver the requested outcome? -- Did I learn something that changes scope, or only implementation? -- Am I about to do more than the user asked? - -### After a user correction - -Treat the correction as a hard boundary update. - -Then: -- Remove the old broader plan. -- Do not defend the discarded work. -- Continue from the narrowed scope. -- If needed, acknowledge briefly and move on. - -## Decision Rules - -Use these rules in order: - -1. Prefer the most direct artifact first. - - Open the relevant file before scanning the whole repo. - - Inspect the specific failing path before designing a general framework. - -2. Prefer the smallest complete fix. - - Solve the asked problem before improving related systems. - - Avoid bonus work unless it is required for correctness. - -3. Prefer internal correction over user interruption. - - If you can shrink back to scope confidently, do it. - - Ask only when the next step changes deliverables, risk, or ownership. - -4. Treat repeated user constraints as priority signals. - - A repeated instruction means your execution style is currently misaligned. - - Tighten scope immediately. - -5. Separate categories of work. - - Code change, investigation, production remediation, cleanup, and documentation are distinct tasks unless the user explicitly combines them. - -## Good Intervention Style - -When you must pause, keep it short and specific: - -- State the potential drift in one sentence. -- Name the tradeoff or boundary. -- Offer the smallest on-scope option first. - -Example: - -"Quick alignment check: I can keep this to the code fix only, or also add an ops cleanup step. I'll stick to the code fix unless you want both." - -## Anti-Patterns - -Do not: - -- Create cleanup scripts, docs, or side tools just because they seem useful. -- Broaden the task after discovering a neighboring problem. -- Continue with a plan the user has already rejected. -- Justify drift with "best practice" when the user asked for a narrower deliverable. -- Hide extra work inside a larger patch. - -## Final Check Before Responding - -Before sending the final answer, verify: - -- The delivered work matches the requested outcome. -- No extra deliverables were added without confirmation. -- Any assumptions are stated briefly. -- Suggested next steps are optional, not bundled into the completed work. -`; +import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; +import { supportsMultimodal } from "./common/model-capabilities"; const COMPACT_PROMPT_BASE = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. @@ -241,28 +90,35 @@ Here's an example of how your output should be structured: `; -const SYSTEM_PROMPT_BASE = `You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +const SYSTEM_PROMPT_BASE = `你是名叫Deep Code的交互式CLI工具,帮助用户完成软件工程任务。 Use the instructions below and the tools available to you to assist the user. -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.`; +重要:严禁编造任何非编程相关的 URL。对于编程链接,仅限使用:1) 用户提供的上下文;2) 你确定的官方文档主域名。在输出前,必须自查该链接是否存在于你的上下文记忆中;若不存在,请明确说明无法提供。`; type PromptToolOptions = { + model?: string; webSearchEnabled?: boolean; }; +const DEFAULT_SKILL_TEMPLATES = ["agent-drift-guard.md", "plan-and-execute.md"]; + function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { - const toolsDir = path.join(extensionRoot, "docs", "tools"); + const toolsDir = path.join(extensionRoot, "templates", "tools"); if (!fs.existsSync(toolsDir)) { return ""; } const entries = fs.readdirSync(toolsDir); const docs = entries - .filter((entry) => entry.endsWith(".md")) + .filter((entry) => entry.endsWith(".md") || entry.endsWith(".md.ejs")) .sort() .map((entry) => { const fullPath = path.join(toolsDir, entry); try { - return fs.readFileSync(fullPath, "utf8").trim(); + const template = fs.readFileSync(fullPath, "utf8"); + const content = entry.endsWith(".ejs") + ? ejs.render(template, { supportsMultimodal: supportsMultimodal(options.model ?? "") }) + : template; + return content.trim(); } catch { return ""; } @@ -272,12 +128,46 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s return docs.join("\n\n"); } -export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}): string { +function readDefaultSkillDocs(extensionRoot: string): Array<{ name: string; content: string }> { + const skillsDir = path.join(extensionRoot, "templates", "skills"); + return DEFAULT_SKILL_TEMPLATES.map((entry) => { + const fullPath = path.join(skillsDir, entry); + try { + return { + name: path.basename(entry, ".md"), + content: fs.readFileSync(fullPath, "utf8").trim(), + }; + } catch { + return null; + } + }).filter((skill): skill is { name: string; content: string } => Boolean(skill?.content)); +} + +export function getDefaultSkillPrompt(): string { + const skillDocs = readDefaultSkillDocs(getExtensionRoot()); + if (skillDocs.length === 0) { + return ""; + } + + const blocks = skillDocs.map( + (skill) => `<${skill.name}-skill> +${skill.content} +` + ); + return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`; +} + +function getCurrentDateAndModelPrompt(model?: string): string { + const date = new Date(); + let prompt = `今天是${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日。随着对话的进行,时间在流逝。`; + prompt += model ? `\n当前LLM模型为${model},对话中可通过/model命令切换模型。` : ""; + return prompt; +} + +export function getSystemPrompt(_projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); - const basePrompt = toolDocs - ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` - : SYSTEM_PROMPT_BASE; - return `${basePrompt}\n\n${getRuntimeContext(projectRoot)}`; + const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; + return basePrompt; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -289,33 +179,51 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { content: message.content, contentParams: message.contentParams, messageParams: message.messageParams, - createTime: message.createTime + createTime: message.createTime, }) ) .join("\n"); return `${COMPACT_PROMPT_BASE}\n\nconversation below:\n\n\`\`\`jsonl\n${jsonl}\n\`\`\``; } -function getRuntimeContext(projectRoot: string): string { +export function getRuntimeContext(projectRoot: string, model?: string): string { const uname = getUnameInfo(); + const shellPath = getShellPathInfo(); + const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; + const runtimeVersions = getRuntimeVersionInfo(); const env = { "root path": projectRoot, pwd: projectRoot, homedir: os.homedir(), "system info": uname, + "shell path": shellPath, + ...shellModeOpts, + ...runtimeVersions, "command installed": { - "ast-grep": checkToolInstalled("ast-grep"), - "ripgrep": checkToolInstalled("rg"), - "jq": checkToolInstalled("jq") - } + ripgrep: checkToolInstalled("rg"), + jq: checkToolInstalled("jq"), + }, }; - return `# Local Workspace Environment\n\n\`\`\`json + return `${getCurrentDateAndModelPrompt(model)} + +# Local Workspace Environment + +\`\`\`json ${JSON.stringify(env, null, 2)} \`\`\``; } function checkToolInstalled(tool: string): boolean { try { + if (process.platform === "win32") { + const bashPath = findGitBashPath(); + execFileSync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { + encoding: "utf8", + stdio: "ignore", + windowsHide: true, + }); + return true; + } execSync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); return true; } catch { @@ -323,8 +231,56 @@ function checkToolInstalled(tool: string): boolean { } } +function getShellPathInfo(): string { + try { + return resolveShellPath(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; +} + +function getRuntimeVersionInfo(): Record { + const versions: Record = {}; + const pythonVersion = getCommandVersion("python3", ["--version"]); + const nodeVersion = getCommandVersion("node", ["--version"]); + + if (pythonVersion) { + versions["python3 version"] = pythonVersion.replace(/^Python\s+/i, ""); + } + if (nodeVersion) { + versions["node version"] = nodeVersion; + } + + return versions; +} + +function getCommandVersion(command: string, args: string[]): string | null { + try { + const commandText = [command, ...args].map(shellSingleQuote).join(" "); + if (process.platform === "win32") { + return execFileSync(findGitBashPath(), ["-lc", `${commandText} 2>&1`], { + encoding: "utf8", + windowsHide: true, + }).trim(); + } + return execSync(`${commandText} 2>&1`, { encoding: "utf8" }).trim(); + } catch { + return null; + } +} + function getUnameInfo(): string { try { + if (process.platform === "win32") { + return execFileSync(findGitBashPath(), ["-lc", "uname -a"], { + encoding: "utf8", + windowsHide: true, + }).trim(); + } return execSync("uname -a", { encoding: "utf8" }).trim(); } catch { return `${os.type()} ${os.release()} ${os.arch()}`; @@ -332,7 +288,14 @@ function getUnameInfo(): string { } function getExtensionRoot(): string { - return path.resolve(__dirname, ".."); + // Prefer `__dirname` which is always available in the CJS bundle output. + // Fall back to `import.meta.url` for ESM test environments (tsx --test). + if (typeof __dirname !== "undefined") { + return path.resolve(__dirname, ".."); + } + + const currentFilePath = fileURLToPath(import.meta.url); + return path.resolve(path.dirname(currentFilePath), ".."); } export type ToolDefinition = { @@ -349,7 +312,7 @@ export type ToolDefinition = { }; }; -export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { +export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDefinition[] = []): ToolDefinition[] { const tools: ToolDefinition[] = [ { type: "function", @@ -368,8 +331,29 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.', }, + sideEffects: { + description: + 'Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use ["unknown"] when the effects cannot be classified safely.', + type: "array", + items: { + type: "string", + enum: [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown", + ], + }, + uniqueItems: true, + }, }, - required: ["command"], + required: ["command", "sideEffects"], additionalProperties: false, }, }, @@ -385,8 +369,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { properties: { questions: { type: "array", - description: - "Questions to present to the user. Usually only one question is needed at a time.", + description: "Questions to present to the user. Usually only one question is needed at a time.", items: { type: "object", properties: { @@ -396,20 +379,17 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, multiSelect: { type: "boolean", - description: - "Whether the user may choose multiple options.", + description: "Whether the user may choose multiple options.", }, options: { type: "array", - description: - "A list of predefined options for the user to choose from.", + description: "A list of predefined options for the user to choose from.", items: { type: "object", properties: { label: { type: "string", - description: - "The display text for the option.", + description: "The display text for the option.", }, description: { type: "string", @@ -433,9 +413,32 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "read", + name: "UpdatePlan", description: - "Read files from the filesystem (text, images, PDFs, notebooks).", + "Update the current task plan. The plan argument must be the complete markdown task list to show as the latest progress state.", + parameters: { + type: "object", + properties: { + plan: { + type: "string", + description: + "The complete markdown task list, including task status markers such as [ ], [>], [x], and optional notes.", + }, + explanation: { + type: "string", + description: "Optional short reason for changing the plan.", + }, + }, + required: ["plan"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "read", + description: "Read files from the filesystem (text, images, PDFs, notebooks).", parameters: { type: "object", properties: { @@ -453,8 +456,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, pages: { type: "string", - description: - 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', + description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', }, }, required: ["file_path"], @@ -466,8 +468,7 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { type: "function", function: { name: "write", - description: - "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", + description: "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", parameters: { type: "object", properties: { @@ -499,7 +500,8 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, snippet_id: { type: "string", - description: "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", + description: + "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", }, old_string: { type: "string", @@ -511,14 +513,12 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, replace_all: { type: "boolean", - description: - "Replace all occurences of old_string (default false)", + description: "Replace all occurences of old_string (default false)", default: false, }, expected_occurrences: { type: "number", - description: - "Expected number of matches, especially useful as a safety check with replace_all", + description: "Expected number of matches, especially useful as a safety check with replace_all", }, }, required: ["old_string", "new_string"], @@ -538,7 +538,8 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { properties: { query: { type: "string", - description: "A search query phrased as a clear, specific natural language question or statement that includes key context.", + description: + "A search query phrased as a clear, specific natural language question or statement that includes key context.", }, }, required: ["query"], @@ -547,5 +548,9 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, }); + for (const tool of externalTools) { + tools.push(tool); + } + return tools; } diff --git a/src/session.ts b/src/session.ts index 010f6eb..a9fc39e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,19 +2,72 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; +import { fileURLToPath } from "url"; import matter from "gray-matter"; +import ejs from "ejs"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; -import { launchNotifyScript } from "./notify"; -import { buildThinkingRequestOptions } from "./openai-thinking"; -import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; -import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } from "./prompt"; -import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +import { launchNotifyScript } from "./common/notify"; +import { buildThinkingRequestOptions } from "./common/openai-thinking"; +import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; +import { + getCompactPrompt, + getDefaultSkillPrompt, + getRuntimeContext, + getSystemPrompt, + getTools, + type ToolDefinition, +} from "./prompt"; +import { + ToolExecutor, + type CreateOpenAIClient, + type ProcessTimeoutControl, + type ProcessTimeoutInfo, + type ToolCallExecution, + type ToolExecutionHooks, +} from "./tools/executor"; +import { McpManager } from "./mcp/mcp-manager"; +import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; +import { logApiError } from "./common/error-logger"; +import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; +import { killProcessTree } from "./common/process-tree"; +import { GitFileHistory } from "./common/file-history"; +import { getSnippet } from "./common/state"; +import { + appendProjectPermissionAllows, + buildPermissionToolExecution, + computeToolCallPermissions, + hasUserPermissionReplies, + normalizeAskPermissions, + parseToolCallForPermissions, + type AskPermissionRequest, + type MessageToolPermission, + type PermissionToolCall, + type UserToolPermission, +} from "./common/permissions"; + +export type { PermissionScope } from "./settings"; +export type { + AskPermissionRequest, + AskPermissionScope, + BashPermissionScope, + MessageToolPermission, + PermissionDecision, + UserToolPermission, +} from "./common/permissions"; const MAX_SESSION_ENTRIES = 50; const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; +const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; +type ChatCompletionDebugOptions = { + enabled?: boolean; + location: string; + baseURL?: string; + params?: Record; +}; + export function getCompactPromptTokenThreshold(model: string): number { return DEEPSEEK_V4_MODELS.has(model) ? DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD @@ -25,6 +78,16 @@ function isUsageRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function summarizeCompletionOptions(options?: Record): Record | undefined { + if (!options) { + return undefined; + } + return { + ...options, + signal: options.signal instanceof AbortSignal ? { aborted: options.signal.aborted } : options.signal, + }; +} + function addUsageValue(current: unknown, next: unknown): unknown { if (typeof next === "number") { return (typeof current === "number" ? current : 0) + next; @@ -42,14 +105,46 @@ function addUsageValue(current: unknown, next: unknown): unknown { return next; } -function accumulateUsage(current: unknown | null, next: unknown | null | undefined): unknown | null { +function accumulateUsage(current: ModelUsage | null, next: unknown | null | undefined): ModelUsage | null { + if (next == null) { + return current ?? null; + } + return addUsageValue(current, next) as ModelUsage; +} + +function usageWithRequestCount(usage: ModelUsage): ModelUsage { + const totalReqs = typeof usage.total_reqs === "number" ? usage.total_reqs + 1 : 1; + return { + ...usage, + total_reqs: totalReqs, + }; +} + +function accumulateUsagePerModel( + current: Record | null | undefined, + model: string, + next: ModelUsage | null | undefined +): Record | null { if (next == null) { return current ?? null; } - return addUsageValue(current, next); + + const usagePerModel = { ...(current ?? {}) }; + const modelName = model.trim() || "unknown"; + usagePerModel[modelName] = accumulateUsage(usagePerModel[modelName] ?? null, usageWithRequestCount(next))!; + return usagePerModel; +} + +function getExtensionRoot(): string { + if (typeof __dirname !== "undefined") { + return path.resolve(__dirname, ".."); + } + + const currentFilePath = fileURLToPath(import.meta.url); + return path.resolve(path.dirname(currentFilePath), ".."); } -function getTotalTokens(usage: unknown | null | undefined): number { +function getTotalTokens(usage: ModelUsage | null | undefined): number { if (!isUsageRecord(usage)) { return 0; } @@ -63,7 +158,35 @@ export type SessionStatus = | "processing" | "waiting_for_user" | "completed" - | "interrupted"; + | "interrupted" + | "ask_permission" + | "permission_denied"; + +export type ModelUsage = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + completion_tokens_details?: Record; + prompt_tokens_details?: Record; + prompt_cache_hit_tokens?: number; + prompt_cache_miss_tokens?: number; + total_reqs?: number; +}; + +export type SessionProcessEntry = { + startTime: string; + command: string; + timeoutMs?: number; + deadlineAt?: string; + timedOut?: boolean; +}; + +export type BashTimeoutAdjustment = { + processId: string; + timeoutMs: number; + deadlineAt: string; + timedOut: boolean; +}; export type SessionEntry = { id: string; @@ -74,11 +197,13 @@ export type SessionEntry = { toolCalls: unknown[] | null; status: SessionStatus; failReason: string | null; - usage: unknown | null; + usage: ModelUsage | null; + usagePerModel: Record | null; activeTokens: number; createTime: string; updateTime: string; - processes: Map | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: process info} + askPermissions?: AskPermissionRequest[]; }; export type SessionsIndex = { @@ -95,7 +220,10 @@ export type MessageMeta = { resultMd?: string; asThinking?: boolean; isSummary?: boolean; + isModelChange?: boolean; skill?: SkillInfo; + permissions?: MessageToolPermission[]; + userPrompt?: UserPromptContent; }; export type SessionMessage = { @@ -111,12 +239,21 @@ export type SessionMessage = { updateTime: string; meta?: MessageMeta; html?: string; + checkpointHash?: string; +}; + +export type UndoTarget = { + message: SessionMessage; + index: number; + canRestoreCode: boolean; }; export type UserPromptContent = { text?: string; imageUrls?: string[]; skills?: SkillInfo[]; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; }; export type SkillInfo = { @@ -129,11 +266,18 @@ export type SkillInfo = { type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; - getResolvedSettings: () => { webSearchTool?: string }; + getResolvedSettings: () => { + model: string; + webSearchTool?: string; + mcpServers?: Record; + permissions?: Required; + }; renderMarkdown: (text: string) => string; onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + onMcpStatusChanged?: () => void; + onProcessStdout?: (pid: number, chunk: string) => void; }; export type LlmStreamProgress = { @@ -148,14 +292,24 @@ export type LlmStreamProgress = { export class SessionManager { private readonly projectRoot: string; private readonly createOpenAIClient: CreateOpenAIClient; - private readonly getResolvedSettings: () => { webSearchTool?: string }; + private readonly getResolvedSettings: () => { + model: string; + webSearchTool?: string; + mcpServers?: Record; + permissions?: Required; + }; private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; + private readonly onMcpStatusChanged?: () => void; + private readonly onProcessStdout?: (pid: number, chunk: string) => void; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); + private readonly processTimeoutControls = new Map(); private readonly toolExecutor: ToolExecutor; + private readonly mcpManager = new McpManager(); + private mcpToolDefinitions: ToolDefinition[] = []; constructor(options: SessionManagerOptions) { this.projectRoot = options.projectRoot; @@ -164,7 +318,35 @@ export class SessionManager { this.onAssistantMessage = options.onAssistantMessage; this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; - this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient); + this.onMcpStatusChanged = options.onMcpStatusChanged; + this.onProcessStdout = options.onProcessStdout; + this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); + this.mcpManager.prepare(this.getResolvedSettings().mcpServers); + } + + async initMcpServers(servers?: Record): Promise { + this.mcpManager.setOnToolsListChanged(() => { + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + }); + // 设置状态变更回调,通知 UI 更新 + this.mcpManager.setOnStatusChanged(() => { + this.onMcpStatusChanged?.(); + }); + await this.mcpManager.initialize(servers); + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + } + + getMcpStatus() { + return this.mcpManager.getStatus(); + } + + async reconnectMcpServer(name: string, config?: McpServerConfig): Promise { + await this.mcpManager.reconnect(name, config); + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + } + + dispose(): void { + this.mcpManager.disconnect(); } private estimateStreamTokens(text: string): number { @@ -209,7 +391,7 @@ export class SessionManager { startedAt, estimatedTokens: Math.round(estimatedTokens), formattedTokens: this.formatEstimatedTokens(estimatedTokens), - phase + phase, }); } @@ -235,13 +417,15 @@ export class SessionManager { client: NonNullable["client"]>, request: Record, options?: Record, - sessionId?: string + sessionId?: string, + debug?: ChatCompletionDebugOptions ): Promise<{ choices?: Array<{ message?: Record }>; - usage?: unknown; + usage?: ModelUsage | null; }> { const requestId = crypto.randomUUID(); const startedAt = new Date().toISOString(); + const startedAtMs = Date.now(); let estimatedTokens = 0; this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "start", sessionId); @@ -250,35 +434,78 @@ export class SessionManager { stream: true, stream_options: { ...(isUsageRecord(request.stream_options) ? request.stream_options : {}), - include_usage: true - } + include_usage: true, + }, }; let response: unknown; try { - response = await (client.chat.completions.create as unknown as ( - body: Record, - options?: Record - ) => Promise)(streamRequest, options); + response = await ( + client.chat.completions.create as unknown as ( + body: Record, + options?: Record + ) => Promise + )(streamRequest, options); } catch (error) { + this.logChatCompletionDebug(debug, { + timestamp: new Date().toISOString(), + location: debug?.location ?? "SessionManager.createChatCompletionStream:create", + requestId, + sessionId, + model: typeof request.model === "string" ? request.model : undefined, + baseURL: debug?.baseURL, + durationMs: Date.now() - startedAtMs, + params: { ...debug?.params, options: summarizeCompletionOptions(options) }, + request: streamRequest, + error: normalizeDebugError(error), + }); + logApiError({ + timestamp: new Date().toISOString(), + location: "SessionManager.createChatCompletionStream:create", + requestId, + sessionId, + model: typeof request.model === "string" ? request.model : undefined, + error: { + name: error instanceof Error ? error.name : "UnknownError", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + request: streamRequest, + }); this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); throw error; } if (!response || typeof (response as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") { this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); - return response as { choices?: Array<{ message?: Record }>; usage?: unknown }; + this.logChatCompletionDebug(debug, { + timestamp: new Date().toISOString(), + location: debug?.location ?? "SessionManager.createChatCompletionStream", + requestId, + sessionId, + model: typeof request.model === "string" ? request.model : undefined, + baseURL: debug?.baseURL, + durationMs: Date.now() - startedAtMs, + params: { ...debug?.params, options: summarizeCompletionOptions(options) }, + request: streamRequest, + response, + }); + return response as { choices?: Array<{ message?: Record }>; usage?: ModelUsage | null }; } let content = ""; let reasoningContent = ""; let refusal: string | null = null; - let usage: unknown = null; - const toolCallsByIndex = new Map(); + let usage: ModelUsage | null = null; + const responseChunks: unknown[] = []; + const toolCallsByIndex = new Map< + number, + { + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + } + >(); const trackText = (value: unknown) => { if (typeof value !== "string" || value.length === 0) { @@ -290,8 +517,11 @@ export class SessionManager { try { for await (const chunk of response as AsyncIterable>) { + if (debug?.enabled) { + responseChunks.push(chunk); + } if ("usage" in chunk && chunk.usage != null) { - usage = chunk.usage; + usage = chunk.usage as ModelUsage; } const choices = Array.isArray(chunk.choices) ? chunk.choices : []; @@ -349,6 +579,34 @@ export class SessionManager { } } } + } catch (error) { + this.logChatCompletionDebug(debug, { + timestamp: new Date().toISOString(), + location: debug?.location ?? "SessionManager.createChatCompletionStream:stream", + requestId, + sessionId, + model: typeof request.model === "string" ? request.model : undefined, + baseURL: debug?.baseURL, + durationMs: Date.now() - startedAtMs, + params: { ...debug?.params, options: summarizeCompletionOptions(options) }, + request: streamRequest, + responseChunks, + error: normalizeDebugError(error), + }); + logApiError({ + timestamp: new Date().toISOString(), + location: "SessionManager.createChatCompletionStream:stream", + requestId, + sessionId, + model: typeof request.model === "string" ? request.model : undefined, + error: { + name: error instanceof Error ? error.name : "UnknownError", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + request: streamRequest, + }); + throw error; } finally { this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); } @@ -356,9 +614,10 @@ export class SessionManager { const toolCalls = Array.from(toolCallsByIndex.entries()) .sort(([left], [right]) => left - right) .map(([, toolCall]) => toolCall); + const normalizedToolCalls = this.normalizeLlmToolCalls(toolCalls); const message: Record = { content }; - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; + if (normalizedToolCalls) { + message.tool_calls = normalizedToolCalls; } if (reasoningContent.length > 0) { message.reasoning_content = reasoningContent; @@ -367,10 +626,34 @@ export class SessionManager { message.refusal = refusal; } - return { + const finalResponse = { choices: [{ message }], - usage + usage, }; + this.logChatCompletionDebug(debug, { + timestamp: new Date().toISOString(), + location: debug?.location ?? "SessionManager.createChatCompletionStream", + requestId, + sessionId, + model: typeof request.model === "string" ? request.model : undefined, + baseURL: debug?.baseURL, + durationMs: Date.now() - startedAtMs, + params: { ...debug?.params, options: summarizeCompletionOptions(options) }, + request: streamRequest, + responseChunks, + response: finalResponse, + }); + return finalResponse; + } + + private logChatCompletionDebug( + debug: ChatCompletionDebugOptions | undefined, + entry: Parameters[0] + ): void { + if (!debug?.enabled) { + return; + } + logOpenAIChatCompletionDebug(entry); } async identifyMatchingSkillNames( @@ -388,30 +671,43 @@ Response in JSON format: \`\`\`\n If none of the available skills match, respond with an empty array, i.e. \`{"skillNames": []}\`.\n The candidate skills are as follows:\n\n`; - const simpleSkills = skills.filter((x) => !x.isLoaded).map((x) => { - return {name: x.name, description: x.description}; - }) + const simpleSkills = skills + .filter((x) => !x.isLoaded) + .map((x) => { + return { name: x.name, description: x.description }; + }); if (simpleSkills.length === 0) { return []; } systemPrompt += "```\n" + JSON.stringify(simpleSkills, null, 2) + "\n```"; - - const { client, model } = this.createOpenAIClient(); + + const { client, model, baseURL, debugLogEnabled } = this.createOpenAIClient(); if (!client) { return []; } try { - const response = await this.createChatCompletionStream(client, { - model, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt } - ], - response_format: { type: "json_object" } - }, options?.signal ? { signal: options.signal } : undefined, options?.sessionId); + const response = await this.createChatCompletionStream( + client, + { + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + }, + options?.signal ? { signal: options.signal } : undefined, + options?.sessionId, + { + enabled: debugLogEnabled, + location: "SessionManager.identifyMatchingSkillNames", + baseURL, + params: { purpose: "skill-matching" }, + } + ); this.throwIfAborted(options?.signal); - + const rawContent = response.choices?.[0]?.message?.content; const content = typeof rawContent === "string" ? rawContent : ""; if (!content) { @@ -422,7 +718,7 @@ The candidate skills are as follows:\n\n`; if (parsed && Array.isArray(parsed.skillNames)) { return parsed.skillNames; } - + return []; } catch (error) { if (this.isAbortLikeError(error) || options?.signal?.aborted) { @@ -435,14 +731,15 @@ The candidate skills are as follows:\n\n`; async listSkills(sessionId?: string): Promise { const homeDir = os.homedir(); const agentsRoot = path.join(homeDir, ".agents", "skills"); - const projectSkillsRoot = path.join(this.projectRoot, ".deepcode", "skills"); + const legacyProjectSkillsRoot = path.join(this.projectRoot, ".deepcode", "skills"); + const projectAgentsSkillsRoot = path.join(this.projectRoot, ".agents", "skills"); const skillsByName = new Map(); const collectSkills = (root: string, displayRoot: string): SkillInfo[] => { if (!fs.existsSync(root)) { return []; } - let entries: fs.Dirent[] = []; + let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { @@ -475,17 +772,17 @@ The candidate skills are as follows:\n\n`; for (const skill of collectSkills(agentsRoot, "~/.agents/skills")) { skillsByName.set(skill.name, skill); } - for (const skill of collectSkills(projectSkillsRoot, "./.deepcode/skills")) { + for (const skill of collectSkills(legacyProjectSkillsRoot, "./.deepcode/skills")) { + skillsByName.set(skill.name, skill); + } + for (const skill of collectSkills(projectAgentsSkillsRoot, "./.agents/skills")) { skillsByName.set(skill.name, skill); } if (sessionId) { const loadedSkillKeys = this.getLoadedSkillKeys(sessionId); for (const skill of skillsByName.values()) { - if ( - loadedSkillKeys.has(this.getSkillKey(skill)) - || loadedSkillKeys.has(this.getSkillKeyByName(skill.name)) - ) { + if (loadedSkillKeys.has(this.getSkillKey(skill)) || loadedSkillKeys.has(this.getSkillKeyByName(skill.name))) { skill.isLoaded = true; } } @@ -529,10 +826,7 @@ The candidate skills are as follows:\n\n`; ? parsed.data.name.trim() : fallbackSkill.name, path: displayPath, - description: - typeof parsed.data.description === "string" - ? parsed.data.description.trim() - : "", + description: typeof parsed.data.description === "string" ? parsed.data.description.trim() : "", }; } catch { return fallbackSkill; @@ -597,8 +891,8 @@ The candidate skills are as follows:\n\n`; return dedupedSkills.map((skill) => { const matchedSkill = - availableSkillsByKey.get(this.getSkillKey(skill)) - ?? availableSkillsByKey.get(this.getSkillKeyByName(skill.name)); + availableSkillsByKey.get(this.getSkillKey(skill)) ?? + availableSkillsByKey.get(this.getSkillKeyByName(skill.name)); if (!matchedSkill) { return skill; } @@ -619,6 +913,12 @@ The candidate skills are as follows:\n\n`; this.activeSessionId = sessionId; } + addSessionSystemMessage(sessionId: string, content: string, visible?: boolean, meta?: MessageMeta): void { + const message = this.buildSystemMessage(sessionId, content, null, visible, meta); + if (sessionId) this.appendSessionMessage(sessionId, message); + this.onAssistantMessage(message, false); + } + async handleUserPrompt(userPrompt: UserPromptContent): Promise { const controller = new AbortController(); this.activePromptController = controller; @@ -645,21 +945,8 @@ The candidate skills are as follows:\n\n`; const signal = controller?.signal; this.throwIfAborted(signal); - if (userPrompt.text) { - const skills = await this.listSkills(); - const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); - this.throwIfAborted(signal); - const skillSet = new Set(skillNames); - const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); - if (Array.isArray(userPrompt.skills)) { - userPrompt.skills.push(...matchedSkill); - } else if (matchedSkill.length > 0) { - userPrompt.skills = matchedSkill; - } - } - userPrompt.skills = await this.normalizeSkills(userPrompt.skills); - this.throwIfAborted(signal); const sessionId = crypto.randomUUID(); + this.ensureFileHistorySession(sessionId); const now = new Date().toISOString(); const index = this.loadSessionsIndex(); const entry: SessionEntry = { @@ -672,22 +959,21 @@ The candidate skills are as follows:\n\n`; status: "pending", failReason: null, usage: null, + usagePerModel: null, activeTokens: 0, createTime: now, updateTime: now, - processes: null + processes: null, }; index.entries.push(entry); - const sortedEntries = index.entries - .slice() - .sort((a, b) => { - const aTime = Date.parse(a.updateTime); - const bTime = Date.parse(b.updateTime); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.updateTime.localeCompare(a.updateTime); - } - return bTime - aTime; - }); + const sortedEntries = index.entries.slice().sort((a, b) => { + const aTime = Date.parse(a.updateTime); + const bTime = Date.parse(b.updateTime); + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return b.updateTime.localeCompare(a.updateTime); + } + return bTime - aTime; + }); const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); const keptIds = new Set(keptEntries.map((item) => item.id)); const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); @@ -695,23 +981,47 @@ The candidate skills are as follows:\n\n`; this.saveSessionsIndex(index); this.removeSessionMessages(droppedEntries.map((item) => item.id)); - const systemPrompt = getSystemPrompt(this.projectRoot, this.getPromptToolOptions()); + const promptToolOptions = this.getPromptToolOptions(); + const systemPrompt = getSystemPrompt(this.projectRoot, promptToolOptions); const systemMessage = this.buildSystemMessage(sessionId, systemPrompt); this.appendSessionMessage(sessionId, systemMessage); + const defaultSkillPrompt = getDefaultSkillPrompt(); + if (defaultSkillPrompt) { + const defaultSkillMessage = this.buildSystemMessage(sessionId, defaultSkillPrompt); + this.appendSessionMessage(sessionId, defaultSkillMessage); + } + + const runtimeContextMessage = this.buildSystemMessage( + sessionId, + getRuntimeContext(this.projectRoot, promptToolOptions.model) + ); + this.appendSessionMessage(sessionId, runtimeContextMessage); + const agentInstructions = this.loadAgentInstructions(); if (agentInstructions) { const instructionsMessage = this.buildSystemMessage(sessionId, agentInstructions); this.appendSessionMessage(sessionId, instructionsMessage); } - const defaultSkillPrompt = `Use the skill document below to assist the user:\n${AGENT_DRIFT_GUARD_SKILL}`; - const defaultSkillMessage = this.buildSystemMessage(sessionId, defaultSkillPrompt); - this.appendSessionMessage(sessionId, defaultSkillMessage); - const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { + const skills = await this.listSkills(); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; + } + } + userPrompt.skills = await this.normalizeSkills(userPrompt.skills); + this.throwIfAborted(signal); + if (userPrompt.skills && userPrompt.skills.length > 0) { for (const skill of userPrompt.skills) { if (skill.isLoaded) { @@ -736,12 +1046,16 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows, { + inheritedPermissions: this.getResolvedSettings().permissions, + }); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, - updateTime: now + askPermissions: undefined, + updateTime: now, })); if (!updated) { @@ -749,9 +1063,24 @@ ${skillMd} return; } - this.closePendingToolCalls(sessionId, "Previous tool call did not complete."); + if (hasUserPermissionReplies(userPrompt) && this.hasTrailingPendingToolCalls(sessionId)) { + this.activeSessionId = sessionId; + await this.activateSession(sessionId, controller, userPrompt); + return; + } + + if (this.isContinuePrompt(userPrompt)) { + this.activeSessionId = sessionId; + await this.activateSession(sessionId, controller, userPrompt); + return; + } + this.reportNewPrompt(); + this.ensureFileHistorySession(sessionId); + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { const skills = await this.listSkills(sessionId); const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); @@ -767,9 +1096,6 @@ ${skillMd} userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); this.throwIfAborted(signal); - const userMessage = this.buildUserMessage(sessionId, userPrompt); - this.appendSessionMessage(sessionId, userMessage); - if (userPrompt.skills && userPrompt.skills.length > 0) { for (const skill of userPrompt.skills) { if (skill.isLoaded) { @@ -789,9 +1115,23 @@ ${skillMd} await this.activateSession(sessionId, controller); } - async activateSession(sessionId: string, controller?: AbortController): Promise { + private isContinuePrompt(userPrompt: UserPromptContent): boolean { + return ( + typeof userPrompt.text === "string" && + userPrompt.text.trim() === "/continue" && + (!userPrompt.imageUrls || userPrompt.imageUrls.length === 0) && + (!userPrompt.skills || userPrompt.skills.length === 0) + ); + } + + async activateSession( + sessionId: string, + controller?: AbortController, + permissionPrompt?: UserPromptContent + ): Promise { const startedAt = Date.now(); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, notify } = this.createOpenAIClient(); + const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = + this.createOpenAIClient(); const now = new Date().toISOString(); if (!client) { @@ -799,13 +1139,17 @@ ${skillMd} ...entry, status: "failed", failReason: "OpenAI API key not found", - updateTime: now + updateTime: now, })); this.onAssistantMessage( - this.buildAssistantMessage(sessionId, "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", null), - false, + this.buildAssistantMessage( + sessionId, + "OpenAI API key not found. Please configure ~/.deepcode/settings.json or ./.deepcode/settings.json.", + null + ), + false ); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); return; } @@ -815,23 +1159,22 @@ ${skillMd} ...entry, status: "interrupted", failReason: "interrupted", - updateTime: now + updateTime: now, })); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); return; } this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "processing", - updateTime: now + updateTime: now, })); this.sessionControllers.set(sessionId, sessionController); - this.closePendingToolCalls(sessionId, "Previous tool call did not complete."); try { - const maxIterations = 80000; // about 1K RMB cost + const maxIterations = 80000; // about 1K RMB cost let toolCalls: unknown[] | null = null; for (let iteration = 0; iteration < maxIterations; iteration++) { @@ -844,33 +1187,64 @@ ${skillMd} return; } + const pendingToolCallMessage = this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)); + if (pendingToolCallMessage.toolCalls.length > 0) { + const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCallMessage.toolCalls, { + permissionOverrides: permissionPrompt?.permissions, + messagePermissions: pendingToolCallMessage.message?.meta?.permissions, + }); + permissionPrompt = await this.appendDeferredPermissionPrompt(sessionId, permissionPrompt, sessionController); + if (this.isInterrupted(sessionId)) { + return; + } + if (toolAppendResult.waitingForUser) { + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + toolCalls: pendingToolCallMessage.toolCalls, + status: "waiting_for_user", + updateTime: new Date().toISOString(), + })); + return; + } + } + const compactPromptTokenThreshold = getCompactPromptTokenThreshold(model); if (session.activeTokens > compactPromptTokenThreshold) { - const message = this.buildAssistantMessage(sessionId, "The conversation is getting long, compacting...", null); + const message = this.buildAssistantMessage( + sessionId, + "The conversation is getting long, compacting...", + null + ); message.meta = { asThinking: true }; this.onAssistantMessage(message, false); await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled); + const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, model); const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); const response = await this.createChatCompletionStream( client, { model, messages, - tools: getTools(this.getPromptToolOptions()), - ...thinkingOptions + tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), + ...thinkingOptions, }, { signal: sessionController.signal }, - sessionId + sessionId, + { + enabled: debugLogEnabled, + location: "SessionManager.activateSession", + baseURL, + params: { iteration, thinkingEnabled, reasoningEffort }, + } ); const message = response.choices?.[0]?.message; const rawContent = message?.content; const content = typeof rawContent === "string" ? rawContent : ""; const rawToolCalls = (message as { tool_calls?: unknown[] } | undefined)?.tool_calls ?? null; - toolCalls = Array.isArray(rawToolCalls) && rawToolCalls.length > 0 ? rawToolCalls : null; + toolCalls = this.normalizeLlmToolCalls(rawToolCalls); const rawThinking = (message as { reasoning_content?: unknown } | undefined)?.reasoning_content; const thinking = typeof rawThinking === "string" ? rawThinking : null; const refusal = (message as { refusal?: string } | undefined)?.refusal ?? null; @@ -880,12 +1254,47 @@ ${skillMd} return; } const assistantMessage = this.buildAssistantMessage(sessionId, content, toolCalls, thinking); + const permissionPlan = toolCalls + ? computeToolCallPermissions({ + sessionId, + projectRoot: this.projectRoot, + toolCalls, + settings: this.getResolvedSettings().permissions, + resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath, + }) + : null; + if (permissionPlan) { + assistantMessage.meta = { + ...(assistantMessage.meta ?? {}), + permissions: permissionPlan.permissions, + }; + } this.appendSessionMessage(sessionId, assistantMessage); this.onAssistantMessage(assistantMessage, true); let waitingForUser = false; + const responseUsage = response.usage ?? null; if (toolCalls) { - const toolAppendResult = await this.appendToolMessages(sessionId, toolCalls); + if (permissionPlan?.askPermissions.length) { + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: content, + assistantThinking: thinking, + assistantRefusal: refusal, + toolCalls, + usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), + activeTokens: getTotalTokens(responseUsage), + status: "ask_permission", + failReason: null, + askPermissions: permissionPlan.askPermissions, + updateTime: new Date().toISOString(), + })); + return; + } + const toolAppendResult = await this.appendToolMessages(sessionId, toolCalls, { + messagePermissions: permissionPlan?.permissions, + }); waitingForUser = toolAppendResult.waitingForUser; } @@ -893,7 +1302,6 @@ ${skillMd} return; } - const responseUsage = response.usage ?? null; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -901,16 +1309,12 @@ ${skillMd} assistantRefusal: refusal, toolCalls, usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), activeTokens: getTotalTokens(responseUsage), - status: refusal - ? "failed" - : waitingForUser - ? "waiting_for_user" - : toolCalls - ? "processing" - : "completed", + status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, - updateTime: new Date().toISOString() + askPermissions: undefined, + updateTime: new Date().toISOString(), })); if (refusal) { @@ -929,43 +1333,40 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "completed", - updateTime: new Date().toISOString() + updateTime: new Date().toISOString(), })); this.onAssistantMessage( - this.buildAssistantMessage(sessionId, "The AI agent has taken several steps but hasn't reached a conclusion yet. Do you want to continue?", null), - false, - ) + this.buildAssistantMessage( + sessionId, + "The AI agent has taken several steps but hasn't reached a conclusion yet. Do you want to continue?", + null + ), + false + ); } catch (error) { const errMessage = error instanceof Error ? error.message : String(error); const aborted = this.isAbortLikeError(error) || sessionController.signal.aborted; - this.closePendingToolCalls( - sessionId, - aborted ? "Interrupted by user." : `Request failed before tool results were recorded: ${errMessage}` - ); this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: aborted ? "interrupted" : "failed", failReason: aborted ? "interrupted" : errMessage, - updateTime: new Date().toISOString() + updateTime: new Date().toISOString(), })); if (!aborted) { - this.onAssistantMessage( - this.buildAssistantMessage(sessionId, `Request failed: ${errMessage}`, null), - false, - ); + this.onAssistantMessage(this.buildAssistantMessage(sessionId, `Request failed: ${errMessage}`, null), false); } } finally { if (this.sessionControllers.get(sessionId) === sessionController) { this.sessionControllers.delete(sessionId); } - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); } } async compactSession(sessionId: string, signal?: AbortSignal): Promise { this.throwIfAborted(signal); - const { client, model, baseURL, thinkingEnabled, reasoningEffort } = this.createOpenAIClient(); + const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled } = this.createOpenAIClient(); if (!client) { return; } @@ -974,14 +1375,12 @@ ${skillMd} return; } - const startIndex = sessionMessages.findIndex( - (message) => message.role !== "system" - ); + const startIndex = sessionMessages.findIndex((message) => message.role !== "system"); if (startIndex === -1) { return; } - const searchStart = Math.floor(startIndex + (sessionMessages.length - startIndex) * 2 / 3); + const searchStart = Math.floor(startIndex + ((sessionMessages.length - startIndex) * 2) / 3); let endIndex = -1; for (let i = Math.max(searchStart, startIndex); i < sessionMessages.length; i += 1) { if (sessionMessages[i].role !== "tool") { @@ -995,11 +1394,22 @@ ${skillMd} const compactPrompt = getCompactPrompt(sessionMessages.slice(startIndex, endIndex)); const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); - const response = await this.createChatCompletionStream(client, { - model, - messages: [{ role: "user", content: compactPrompt }], - ...thinkingOptions - }, signal ? { signal } : undefined, sessionId); + const response = await this.createChatCompletionStream( + client, + { + model, + messages: [{ role: "user", content: compactPrompt }], + ...thinkingOptions, + }, + signal ? { signal } : undefined, + sessionId, + { + enabled: debugLogEnabled, + location: "SessionManager.compactSession", + baseURL, + params: { thinkingEnabled, reasoningEffort }, + } + ); this.throwIfAborted(signal); const rawLlmResponse = response.choices?.[0]?.message?.content; const llmResponse = typeof rawLlmResponse === "string" ? rawLlmResponse : ""; @@ -1010,8 +1420,9 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), activeTokens: getTotalTokens(responseUsage), - updateTime: now + updateTime: now, })); for (let i = startIndex; i < endIndex; i += 1) { @@ -1030,16 +1441,17 @@ ${skillMd} createTime: now, updateTime: now, meta: { - isSummary: true - } + isSummary: true, + }, }; sessionMessages.splice(endIndex, 0, summaryMessage); this.saveSessionMessages(sessionId, sessionMessages); } - private getPromptToolOptions(): { webSearchEnabled: boolean } { + private getPromptToolOptions(): { model: string; webSearchEnabled: boolean } { return { - webSearchEnabled: true + model: this.getResolvedSettings().model, + webSearchEnabled: true, }; } @@ -1049,28 +1461,20 @@ ${skillMd} return; } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), NEW_PROMPT_REPORT_TIMEOUT_MS); + void fetch(DEFAULT_NEW_PROMPT_API_URL, { method: "POST", headers: { "Content-Type": "application/json", - Token: machineId + Token: machineId, }, - body: JSON.stringify({}) + body: JSON.stringify({}), + signal: controller.signal, }) - .then(async (response) => { - if (response.ok) { - return; - } - - const body = await response.text().catch(() => ""); - throw new Error( - `New prompt API request failed with status ${response.status}${body ? `: ${body}` : ""}` - ); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.warn(`Failed to report new prompt: ${message}`); - }); + .catch(() => {}) + .finally(() => clearTimeout(timeout)); } interruptActiveSession(): void { @@ -1091,17 +1495,12 @@ ${skillMd} const killedPids: number[] = []; const failedPids: number[] = []; for (const pid of processIds) { - const killedGroup = this.killProcessGroup(pid); - if (killedGroup) { + this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid)); + if (killProcessTree(pid, "SIGKILL")) { killedPids.push(pid); continue; } - try { - process.kill(pid, "SIGKILL"); - killedPids.push(pid); - } catch { - failedPids.push(pid); - } + failedPids.push(pid); } const controller = this.sessionControllers.get(sessionId); @@ -1116,11 +1515,9 @@ ${skillMd} status: "interrupted", failReason: "interrupted", processes: null, - updateTime: now + updateTime: now, })); - this.closePendingToolCalls(sessionId, "Interrupted by user."); - const contentParts = ["Interrupted."]; if (killedPids.length > 0) { contentParts.push(`Killed processes: ${killedPids.join(", ")}.`); @@ -1129,16 +1526,58 @@ ${skillMd} contentParts.push(`Failed to kill processes: ${failedPids.join(", ")}.`); } - this.onAssistantMessage( - this.buildUserMessage(sessionId, { text: contentParts.join(" ") }), - false, - ); + this.onAssistantMessage(this.buildUserMessage(sessionId, { text: contentParts.join(" ") }), false); } private isInterrupted(sessionId: string): boolean { return !this.sessionControllers.has(sessionId); } + /** + * Mark a session's permission as denied by the user. + * Updates the session entry status and failReason so the denial is visible in the session list. + */ + denySessionPermission(sessionId: string, reason?: string): void { + const now = new Date().toISOString(); + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + status: "permission_denied", + failReason: reason ?? "Permission denied by user", + updateTime: now, + })); + } + + adjustActiveBashTimeout(deltaMs: number): BashTimeoutAdjustment | null { + const sessionId = this.activeSessionId; + if (!sessionId || !Number.isFinite(deltaMs)) { + return null; + } + const session = this.getSession(sessionId); + if (!session?.processes) { + return null; + } + + let selectedPid: string | null = null; + for (const pid of session.processes.keys()) { + if (this.processTimeoutControls.has(this.getProcessControlKey(sessionId, pid))) { + selectedPid = pid; + } + } + if (!selectedPid) { + return null; + } + + const control = this.processTimeoutControls.get(this.getProcessControlKey(sessionId, selectedPid)); + if (!control) { + return null; + } + + const current = control.getInfo(); + const next = control.setTimeoutMs(current.timeoutMs + deltaMs); + this.updateSessionProcessTimeout(sessionId, selectedPid, next); + return this.buildBashTimeoutAdjustment(selectedPid, next); + } + listSessions(): SessionEntry[] { const index = this.loadSessionsIndex(); return index.entries; @@ -1149,6 +1588,28 @@ ${skillMd} return index.entries.find((entry) => entry.id === sessionId) ?? null; } + /** + * Delete a session by its ID. + * Removes the session entry from the index and deletes the associated messages file. + * Returns true if the session was found and deleted, false otherwise. + */ + deleteSession(sessionId: string): boolean { + const index = this.loadSessionsIndex(); + const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId); + if (entryIndex === -1) { + return false; + } + + // Remove from index + index.entries.splice(entryIndex, 1); + this.saveSessionsIndex(index); + + // Remove messages file + this.removeSessionMessages([sessionId]); + + return true; + } + listSessionMessages(sessionId: string): SessionMessage[] { const messagePath = this.getSessionMessagesPath(sessionId); if (!fs.existsSync(messagePath)) { @@ -1169,6 +1630,61 @@ ${skillMd} return messages; } + listUndoTargets(sessionId: string): UndoTarget[] { + return this.listSessionMessages(sessionId) + .map((message, index) => ({ message, index })) + .filter(({ message }) => this.isUndoTargetMessage(message)) + .map(({ message, index }) => ({ + message, + index, + canRestoreCode: Boolean( + message.checkpointHash && this.canRestoreCheckpointHash(sessionId, message.checkpointHash) + ), + })); + } + + restoreSessionConversation(sessionId: string, messageId: string): SessionMessage[] { + const messages = this.listSessionMessages(sessionId); + const targetIndex = messages.findIndex((message) => message.id === messageId); + if (targetIndex === -1) { + throw new Error("Selected message was not found in this session."); + } + + const keptMessages = messages.slice(0, targetIndex); + this.saveSessionMessages(sessionId, keptMessages); + const now = new Date().toISOString(); + const latestAssistant = [...keptMessages].reverse().find((message) => message.role === "assistant"); + const latestAssistantParams = latestAssistant?.messageParams as + | { tool_calls?: unknown[]; reasoning_content?: string } + | null + | undefined; + + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: latestAssistant?.content ?? null, + assistantThinking: + typeof latestAssistantParams?.reasoning_content === "string" ? latestAssistantParams.reasoning_content : null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + processes: null, + updateTime: now, + })); + return keptMessages; + } + + restoreSessionCode(sessionId: string, messageId: string): void { + const message = this.listSessionMessages(sessionId).find((item) => item.id === messageId); + if (!message) { + throw new Error("Selected message was not found in this session."); + } + if (!message.checkpointHash) { + throw new Error("Selected message has no code checkpoint."); + } + this.restoreCheckpointHash(sessionId, message.checkpointHash); + } + private normalizeSessionMessage(message: SessionMessage): SessionMessage { if (message.role !== "tool") { return message; @@ -1180,8 +1696,7 @@ ${skillMd} nextMeta.paramsMd = normalizedParamsMd; } - const normalizedResultMd = - typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; + const normalizedResultMd = typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; if (nextMeta && normalizedResultMd) { nextMeta.resultMd = normalizedResultMd; } @@ -1189,7 +1704,7 @@ ${skillMd} return { ...message, visible: typeof message.content === "string" ? !this.isInvisibleExecution(message.content) : message.visible, - meta: nextMeta + meta: nextMeta, }; } @@ -1208,30 +1723,98 @@ ${skillMd} return { projectCode, projectDir, sessionsIndexPath }; } - private ensureProjectDir(): string { + private getFileHistory(): GitFileHistory { + return new GitFileHistory(this.projectRoot, this.getFileHistoryGitDir()); + } + + private getFileHistoryGitDir(): string { const { projectDir } = this.getProjectStorage(); - fs.mkdirSync(projectDir, { recursive: true }); - return projectDir; + return path.join(projectDir, "file-history", ".git"); } - private loadSessionsIndex(): SessionsIndex { - const { sessionsIndexPath } = this.getProjectStorage(); - this.ensureProjectDir(); + private ensureFileHistorySession(sessionId: string): string | undefined { + return this.getFileHistory().ensureSession(sessionId); + } - if (!fs.existsSync(sessionsIndexPath)) { - return { version: 1, entries: [], originalPath: this.projectRoot }; + private getCurrentCheckpointHash(sessionId: string): string | undefined { + return this.getFileHistory().getCurrentCheckpointHash(sessionId); + } + + private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void { + const fileHistory = this.getFileHistory(); + const previousHash = fileHistory.ensureSession(sessionId); + if (!previousHash) { + return; } + this.updateLatestUserCheckpointHash(sessionId, undefined, previousHash); + const nextHash = fileHistory.recordCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint"); + if (nextHash && nextHash !== previousHash) { + this.updateLatestUserCheckpointHash(sessionId, previousHash, nextHash); + } + } - try { - const raw = fs.readFileSync(sessionsIndexPath, "utf8"); - const parsed = JSON.parse(raw) as SessionsIndex; - const entries = Array.isArray(parsed.entries) - ? parsed.entries.map((entry) => this.normalizeSessionEntry(entry)) - : []; - return { - version: 1, + private recordFileMutationCheckpoint(sessionId: string, filePath: string): void { + const fileHistory = this.getFileHistory(); + fileHistory.ensureSession(sessionId); + fileHistory.recordCheckpoint(sessionId, [filePath], "File mutation checkpoint"); + } + + private updateLatestUserCheckpointHash(sessionId: string, previousHash: string | undefined, nextHash: string): void { + const messages = this.listSessionMessages(sessionId); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message || !this.isUndoTargetMessage(message)) { + continue; + } + if (message.checkpointHash && message.checkpointHash !== previousHash) { + return; + } + messages[index] = { + ...message, + checkpointHash: nextHash, + updateTime: new Date().toISOString(), + }; + this.saveSessionMessages(sessionId, messages); + return; + } + } + + private canRestoreCheckpointHash(sessionId: string, checkpointHash: string): boolean { + return this.getFileHistory().canRestore(sessionId, checkpointHash); + } + + private restoreCheckpointHash(sessionId: string, checkpointHash: string): void { + this.getFileHistory().restore(sessionId, checkpointHash); + } + + private isUndoTargetMessage(message: SessionMessage): boolean { + return message.role === "user" && message.visible && !message.compacted; + } + + private ensureProjectDir(): string { + const { projectDir } = this.getProjectStorage(); + fs.mkdirSync(projectDir, { recursive: true }); + return projectDir; + } + + private loadSessionsIndex(): SessionsIndex { + const { sessionsIndexPath } = this.getProjectStorage(); + this.ensureProjectDir(); + + if (!fs.existsSync(sessionsIndexPath)) { + return { version: 1, entries: [], originalPath: this.projectRoot }; + } + + try { + const raw = fs.readFileSync(sessionsIndexPath, "utf8"); + const parsed = JSON.parse(raw) as SessionsIndex; + const entries = Array.isArray(parsed.entries) + ? parsed.entries.map((entry) => this.normalizeSessionEntry(entry)) + : []; + return { + version: 1, entries, - originalPath: parsed.originalPath || this.projectRoot + originalPath: parsed.originalPath || this.projectRoot, }; } catch { return { version: 1, entries: [], originalPath: this.projectRoot }; @@ -1245,9 +1828,9 @@ ${skillMd} version: 1, entries: index.entries.map((entry) => ({ ...entry, - processes: this.serializeProcesses(entry.processes) + processes: this.serializeProcesses(entry.processes), })), - originalPath: this.projectRoot + originalPath: this.projectRoot, }; fs.writeFileSync(sessionsIndexPath, JSON.stringify(normalized, null, 2), "utf8"); } @@ -1283,10 +1866,7 @@ ${skillMd} fs.writeFileSync(messagePath, payload ? `${payload}\n` : "", "utf8"); } - private updateSessionEntry( - sessionId: string, - updater: (entry: SessionEntry) => SessionEntry - ): SessionEntry | null { + private updateSessionEntry(sessionId: string, updater: (entry: SessionEntry) => SessionEntry): SessionEntry | null { const index = this.loadSessionsIndex(); const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId); if (entryIndex === -1) { @@ -1303,12 +1883,12 @@ ${skillMd} private buildUserMessage(sessionId: string, prompt: UserPromptContent): SessionMessage { const now = new Date().toISOString(); const imageParams = - prompt.imageUrls - ?.filter((url) => Boolean(url)) - .map((url) => ({ - type: "image_url", - image_url: { url } - })) ?? []; + prompt.imageUrls + ?.filter((url) => Boolean(url)) + .map((url) => ({ + type: "image_url", + image_url: { url }, + })) ?? []; return { id: crypto.randomUUID(), @@ -1320,37 +1900,76 @@ ${skillMd} compacted: false, visible: true, createTime: now, - updateTime: now + updateTime: now, + meta: { userPrompt: this.cloneUserPromptForMeta(prompt) }, + checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } - private loadAgentInstructions(): string | null { + private renderInitCommandPrompt(): string { + const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "init_command.md.ejs"); + const template = fs.readFileSync(templatePath, "utf8"); + return ejs.render(template, { + agentsMdFile: this.getEffectiveProjectAgentsMdFile(), + }); + } + + private getEffectiveProjectAgentsMdFile(): string | null { + return this.loadProjectAgentInstructions()?.displayPath ?? null; + } + + private loadProjectAgentInstructions(): { content: string; displayPath: string } | null { const candidatePaths = [ - path.join(this.projectRoot, ".deepcode", "AGENTS.md"), - path.join(os.homedir(), ".deepcode", "AGENTS.md") + { + absolutePath: path.join(this.projectRoot, ".deepcode", "AGENTS.md"), + displayPath: "./.deepcode/AGENTS.md", + }, + { + absolutePath: path.join(this.projectRoot, "AGENTS.md"), + displayPath: "./AGENTS.md", + }, ]; for (const candidatePath of candidatePaths) { - try { - if (!fs.existsSync(candidatePath)) { - continue; - } - const content = fs.readFileSync(candidatePath, "utf8").trim(); - if (content) { - return content; - } - } catch { - continue; + const content = this.readNonEmptyFile(candidatePath.absolutePath); + if (content) { + return { + content, + displayPath: candidatePath.displayPath, + }; } } return null; } + private readNonEmptyFile(filePath: string): string | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const content = fs.readFileSync(filePath, "utf8").trim(); + return content || null; + } catch { + return null; + } + } + + private loadAgentInstructions(): string | null { + const projectInstructions = this.loadProjectAgentInstructions(); + if (projectInstructions) { + return projectInstructions.content; + } + + return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); + } + private buildSystemMessage( sessionId: string, content: string, - contentParams: unknown | null = null + contentParams: unknown | null = null, + visible = false, + meta?: MessageMeta ): SessionMessage { const now = new Date().toISOString(); return { @@ -1361,9 +1980,10 @@ ${skillMd} contentParams, messageParams: null, compacted: false, - visible: false, + visible, createTime: now, - updateTime: now + updateTime: now, + meta, }; } @@ -1385,10 +2005,10 @@ ${skillMd} } private buildAssistantMessage( - sessionId: string, - content: string | null, - toolCalls: unknown[] | null, - reasoningContent?: string | null + sessionId: string, + content: string | null, + toolCalls: unknown[] | null, + reasoningContent?: string | null ): SessionMessage { const now = new Date().toISOString(); const hasReasoningContent = reasoningContent != null; @@ -1411,10 +2031,37 @@ ${skillMd} visible: (content || reasoningContent || "").trim() ? true : false, createTime: now, updateTime: now, - meta: toolCalls ? { asThinking: true } : undefined + meta: toolCalls ? { asThinking: true } : undefined, }; } + private generateToolCallId(): string { + return crypto.randomBytes(16).toString("hex"); + } + + private normalizeLlmToolCalls(rawToolCalls: unknown[] | null | undefined): unknown[] | null { + if (!Array.isArray(rawToolCalls) || rawToolCalls.length === 0) { + return null; + } + + return rawToolCalls.map((toolCall) => { + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) { + return toolCall; + } + + const record = toolCall as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (id) { + return toolCall; + } + + return { + ...record, + id: this.generateToolCallId(), + }; + }); + } + private buildToolMessage( sessionId: string, toolCallId: string, @@ -1439,20 +2086,44 @@ ${skillMd} meta: { function: toolFunction ?? undefined, paramsMd, - resultMd - } + resultMd, + }, }; } private async appendToolMessages( sessionId: string, - toolCalls: unknown[] + toolCalls: unknown[], + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } = {} ): Promise<{ waitingForUser: boolean }> { - const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { + const hooks: ToolExecutionHooks = { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), - shouldStop: () => this.isInterrupted(sessionId) - }); + onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), + onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control), + onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath), + onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath), + shouldStop: () => this.isInterrupted(sessionId), + }; + const parsedToolCalls = toolCalls + .map((toolCall) => parseToolCallForPermissions(toolCall)) + .filter((toolCall): toolCall is PermissionToolCall => Boolean(toolCall)); + const toolExecutions: ToolCallExecution[] = []; + for (const toolCall of parsedToolCalls) { + if (hooks.shouldStop?.()) { + break; + } + const blockedResult = buildPermissionToolExecution(toolCall, options); + if (blockedResult) { + toolExecutions.push(blockedResult); + continue; + } + const executions = await this.toolExecutor.executeToolCalls(sessionId, [toolCall], hooks); + toolExecutions.push(...executions); + } if (this.isInterrupted(sessionId)) { return { waitingForUser: false }; } @@ -1463,12 +2134,7 @@ ${skillMd} waitingForUser = true; } const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); - const toolMessage = this.buildToolMessage( - sessionId, - execution.toolCallId, - execution.content, - toolFunction - ); + const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); @@ -1477,11 +2143,7 @@ ${skillMd} continue; } followUpMessages.push( - this.buildSystemMessage( - sessionId, - followUpMessage.content, - followUpMessage.contentParams ?? null - ) + this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null) ); } } @@ -1492,56 +2154,292 @@ ${skillMd} return { waitingForUser }; } + private cloneUserPromptForMeta(prompt: UserPromptContent): UserPromptContent { + return { + text: prompt.text, + imageUrls: prompt.imageUrls ? [...prompt.imageUrls] : undefined, + skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined, + permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined, + alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined, + }; + } + + private hasTrailingPendingToolCalls(sessionId: string): boolean { + return this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)).toolCalls.length > 0; + } + + private async appendDeferredPermissionPrompt( + sessionId: string, + userPrompt: UserPromptContent | undefined, + controller: AbortController + ): Promise { + if (!userPrompt || this.isContinuePrompt(userPrompt)) { + return undefined; + } + const text = userPrompt.text ?? ""; + const hasUserContent = + text.trim().length > 0 || + (Array.isArray(userPrompt.imageUrls) && userPrompt.imageUrls.length > 0) || + (Array.isArray(userPrompt.skills) && userPrompt.skills.length > 0); + if (!hasUserContent) { + return undefined; + } + this.reportNewPrompt(); + const signal = controller.signal; + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { + const skills = await this.listSkills(sessionId); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; + } + } + userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); + this.throwIfAborted(signal); + if (userPrompt.skills && userPrompt.skills.length > 0) { + for (const skill of userPrompt.skills) { + if (skill.isLoaded) { + continue; + } + const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8"); + const skillPrompt = `Use the skill document below to assist the user:\n +<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}"> +${skillMd} +`; + const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill); + this.appendSessionMessage(sessionId, skillMessage); + this.onAssistantMessage(skillMessage, true); + } + } + return undefined; + } + private buildOpenAIMessages( messages: SessionMessage[], thinkingEnabled: boolean, + model: string ): ChatCompletionMessageParam[] { - return messages - .filter((message) => !message.compacted) - .map((message) => { - const base: ChatCompletionMessageParam = { - role: message.role, - content: message.content ?? "" - } as ChatCompletionMessageParam; - - const messageParams = message.messageParams as - | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } - | null - | undefined; - if (messageParams?.tool_calls) { - (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; - } - if (messageParams?.tool_call_id) { - (base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id; - } - if (typeof messageParams?.reasoning_content === "string") { - (base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content; - } else if (thinkingEnabled && message.role === "assistant") { - // Thinking-mode providers require every replayed assistant message - // to include the reasoning_content field, even when it is empty. - (base as { reasoning_content?: string }).reasoning_content = ""; - } + const activeMessages = messages.filter((message) => !message.compacted); + const toolPairings = this.pairToolMessages(activeMessages); + const openAIMessages: ChatCompletionMessageParam[] = []; - if ((message.role === "user" || message.role === "system") && message.contentParams) { - const contentParts: ChatCompletionContentPart[] = []; - if (message.content) { - contentParts.push({ type: "text", text: message.content }); - } - const params = Array.isArray(message.contentParams) - ? message.contentParams - : [message.contentParams]; - for (const param of params) { - if (param && typeof param === "object") { - contentParts.push(param as ChatCompletionContentPart); - } - } - const contentValue: string | ChatCompletionContentPart[] = - contentParts.length > 0 ? contentParts : message.content ?? ""; - (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; - } + for (let index = 0; index < activeMessages.length; index += 1) { + const message = activeMessages[index]; + if (message.role === "tool") { + continue; + } - return base; - }); + openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled, model)); + + const toolCalls = this.getAssistantToolCalls(message); + if (toolCalls.length === 0) { + continue; + } + + for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { + const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); + if (!toolCallId) { + continue; + } + + const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); + if (pairedToolIndex != null) { + openAIMessages.push( + this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled, model) + ); + continue; + } + + openAIMessages.push(this.buildInterruptedOpenAIToolMessage(toolCalls, toolCallId)); + } + } + + return openAIMessages; + } + + private sessionMessageToOpenAIMessage( + message: SessionMessage, + thinkingEnabled: boolean, + model: string + ): ChatCompletionMessageParam { + const content = this.renderOpenAIMessageContent(message); + const base: ChatCompletionMessageParam = { + role: message.role, + content, + } as ChatCompletionMessageParam; + + const messageParams = message.messageParams as + | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } + | null + | undefined; + if (messageParams?.tool_calls) { + (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; + } + if (messageParams?.tool_call_id) { + (base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id; + } + if (typeof messageParams?.reasoning_content === "string") { + (base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content; + } else if (thinkingEnabled && message.role === "assistant") { + // Thinking-mode providers require every replayed assistant message + // to include the reasoning_content field, even when it is empty. + (base as { reasoning_content?: string }).reasoning_content = ""; + } + + if ((message.role === "user" || message.role === "system") && message.contentParams) { + const contentParts: ChatCompletionContentPart[] = []; + if (content) { + contentParts.push({ type: "text", text: content }); + } + const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; + for (const param of params) { + const part = param as ChatCompletionContentPart; + if (part && (part.type !== "image_url" || supportsMultimodal(model))) { + contentParts.push(part); + } + } + const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; + (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; + } + + return base; + } + + private renderOpenAIMessageContent(message: SessionMessage): string { + if (message.role === "user" && message.content === "/init") { + return this.renderInitCommandPrompt(); + } + return message.content ?? ""; + } + + private pairToolMessages(messages: SessionMessage[]): Map { + const pairings = new Map(); + const usedToolMessageIndexes = new Set(); + + for (let assistantIndex = 0; assistantIndex < messages.length; assistantIndex += 1) { + const toolCalls = this.getAssistantToolCalls(messages[assistantIndex]); + for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { + const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); + if (!toolCallId) { + continue; + } + + const toolIndex = this.findPairableToolMessageIndex( + messages, + assistantIndex, + toolCallId, + usedToolMessageIndexes + ); + if (toolIndex == null) { + continue; + } + + usedToolMessageIndexes.add(toolIndex); + pairings.set(this.buildToolPairingKey(assistantIndex, toolCallIndex), toolIndex); + } + } + + return pairings; + } + + private getTrailingPendingToolCallMessage( + messages: SessionMessage[] + ): { message: SessionMessage; toolCalls: unknown[] } | { message: null; toolCalls: [] } { + const activeMessages = messages.filter((message) => !message.compacted); + const latestMessage = activeMessages[activeMessages.length - 1]; + if (!latestMessage || latestMessage.role !== "assistant") { + return { message: null, toolCalls: [] }; + } + + const toolCalls = this.getAssistantToolCalls(latestMessage); + if (toolCalls.length === 0) { + return { message: null, toolCalls: [] }; + } + return { + message: latestMessage, + toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))), + }; + } + + private findPairableToolMessageIndex( + messages: SessionMessage[], + assistantIndex: number, + toolCallId: string, + usedToolMessageIndexes: Set + ): number | null { + let firstMatchingIndex: number | null = null; + for (let index = assistantIndex + 1; index < messages.length; index += 1) { + const message = messages[index]; + if (message.role !== "tool" || usedToolMessageIndexes.has(index)) { + continue; + } + + const candidateToolCallId = this.getToolMessageCallId(message); + if (candidateToolCallId !== toolCallId) { + continue; + } + + if (firstMatchingIndex == null) { + firstMatchingIndex = index; + } + if (!this.isInterruptedToolMessage(message)) { + return index; + } + } + return firstMatchingIndex; + } + + private getAssistantToolCalls(message: SessionMessage): unknown[] { + if (message.role !== "assistant") { + return []; + } + const messageParams = message.messageParams as { tool_calls?: unknown[] } | null; + return Array.isArray(messageParams?.tool_calls) ? messageParams.tool_calls : []; + } + + private getToolCallId(toolCall: unknown): string | null { + if (!toolCall || typeof toolCall !== "object") { + return null; + } + const id = (toolCall as { id?: unknown }).id; + return typeof id === "string" && id ? id : null; + } + + private getToolMessageCallId(message: SessionMessage): string | null { + const messageParams = message.messageParams as { tool_call_id?: unknown } | null; + const toolCallId = messageParams?.tool_call_id; + return typeof toolCallId === "string" && toolCallId ? toolCallId : null; + } + + private buildToolPairingKey(assistantIndex: number, toolCallIndex: number): string { + return `${assistantIndex}:${toolCallIndex}`; + } + + private isInterruptedToolMessage(message: SessionMessage): boolean { + if (typeof message.content !== "string" || !message.content.trim()) { + return false; + } + try { + const parsed = JSON.parse(message.content) as { metadata?: { interrupted?: unknown } }; + return parsed.metadata?.interrupted === true; + } catch { + return false; + } + } + + private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { + const toolFunction = this.findToolFunction(toolCalls, toolCallId); + return { + role: "tool", + content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."), + tool_call_id: toolCallId, + } as ChatCompletionMessageParam; } private findToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { @@ -1597,6 +2495,10 @@ ${skillMd} if (description) { return description; } + } else if (toolName === "UpdatePlan") { + return typeof args.explanation === "string" ? args.explanation.trim() : ""; + } else if (toolName === "write") { + return typeof args.file_path === "string" ? args.file_path.trim() : ""; } const firstKey = Object.keys(args)[0]; @@ -1657,7 +2559,8 @@ ${skillMd} private maybeNotifyTaskCompletion( sessionId: string, notifyCommand: string | undefined, - startedAt: number + startedAt: number, + configuredEnv: Record = {} ): void { if (!notifyCommand) { return; @@ -1668,7 +2571,23 @@ ${skillMd} return; } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot); + // Find the last assistant message body for the BODY env variable. + let body: string | undefined; + const messages = this.listSessionMessages(sessionId); + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && msg.role === "assistant" && msg.content) { + body = msg.content; + break; + } + } + + launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, { + status: session.status, + failReason: session.failReason ?? undefined, + body, + title: session.summary ?? undefined, + }); } private addSessionProcess(sessionId: string, processId: string | number, command: string): void { @@ -1679,103 +2598,85 @@ ${skillMd} return { ...entry, processes, - updateTime: now + updateTime: now, }; }); } private removeSessionProcess(sessionId: string, processId: string | number): void { const now = new Date().toISOString(); + this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId)); this.updateSessionEntry(sessionId, (entry) => { const processes = new Map(entry.processes ?? []); processes.delete(String(processId)); return { ...entry, processes: processes.size > 0 ? processes : null, - updateTime: now + updateTime: now, }; }); } - private getProcessIds(processes: Map | null): number[] { - if (!processes) { - return []; - } - const ids: number[] = []; - for (const pid of processes.keys()) { - const parsed = Number(pid); - if (Number.isInteger(parsed) && parsed > 0) { - ids.push(parsed); - } + private setSessionProcessTimeoutControl( + sessionId: string, + processId: string | number, + control: ProcessTimeoutControl | null + ): void { + const key = this.getProcessControlKey(sessionId, processId); + if (!control) { + this.processTimeoutControls.delete(key); + return; } - return ids; - } - - private closePendingToolCalls(sessionId: string, reason: string): void { - const messages = this.listSessionMessages(sessionId); - let changed = false; - - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]; - if (message.role !== "assistant") { - continue; - } - const messageParams = message.messageParams as { tool_calls?: unknown[] } | null; - const toolCalls = messageParams?.tool_calls; - if (!Array.isArray(toolCalls) || toolCalls.length === 0) { - continue; - } - - const expectedToolCallIds = this.getExpectedToolCallIds(toolCalls); - if (expectedToolCallIds.length === 0) { - continue; - } - - let cursor = index + 1; - const respondedToolCallIds = new Set(); - while (cursor < messages.length && messages[cursor].role === "tool") { - const toolCallId = (messages[cursor].messageParams as { tool_call_id?: unknown } | null)?.tool_call_id; - if (typeof toolCallId === "string" && toolCallId) { - respondedToolCallIds.add(toolCallId); - } - cursor += 1; - } + this.processTimeoutControls.set(key, control); + this.updateSessionProcessTimeout(sessionId, processId, control.getInfo()); + } - const missingToolCallIds = expectedToolCallIds.filter((toolCallId) => !respondedToolCallIds.has(toolCallId)); - if (missingToolCallIds.length === 0) { - continue; + private updateSessionProcessTimeout(sessionId: string, processId: string | number, info: ProcessTimeoutInfo): void { + const now = new Date().toISOString(); + this.updateSessionEntry(sessionId, (entry) => { + const processes = new Map(entry.processes ?? []); + const pid = String(processId); + const processInfo = processes.get(pid); + if (!processInfo) { + return entry; } - - const toolMessages = missingToolCallIds.map((toolCallId) => { - const toolFunction = this.findToolFunction(toolCalls, toolCallId); - return this.buildToolMessage( - sessionId, - toolCallId, - this.buildInterruptedToolResult(toolFunction, reason), - toolFunction - ); + processes.set(pid, { + ...processInfo, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, }); + return { + ...entry, + processes, + updateTime: now, + }; + }); + } - messages.splice(cursor, 0, ...toolMessages); - changed = true; - index = cursor + toolMessages.length - 1; - } + private buildBashTimeoutAdjustment(processId: string, info: ProcessTimeoutInfo): BashTimeoutAdjustment { + return { + processId, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }; + } - if (changed) { - this.saveSessionMessages(sessionId, messages); - } + private getProcessControlKey(sessionId: string, processId: string | number): string { + return `${sessionId}:${String(processId)}`; } - private getExpectedToolCallIds(toolCalls: unknown[]): string[] { - const ids: string[] = []; - for (const toolCall of toolCalls) { - if (!toolCall || typeof toolCall !== "object") { - continue; - } - const id = (toolCall as { id?: unknown }).id; - if (typeof id === "string" && id) { - ids.push(id); + private getProcessIds(processes: Map | null): number[] { + if (!processes) { + return []; + } + const ids: number[] = []; + for (const pid of processes.keys()) { + const parsed = Number(pid); + if (Number.isInteger(parsed) && parsed > 0) { + ids.push(parsed); } } return ids; @@ -1784,7 +2685,7 @@ ${skillMd} private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string { const toolName = toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string" - ? ((toolFunction as { name: string }).name) + ? (toolFunction as { name: string }).name : "tool"; return JSON.stringify( { @@ -1792,28 +2693,16 @@ ${skillMd} name: toolName, error: reason, metadata: { - interrupted: true - } + interrupted: true, + }, }, null, 2 ); } - private killProcessGroup(pid: number): boolean { - if (process.platform === "win32") { - return false; - } - try { - process.kill(-pid, "SIGKILL"); - return true; - } catch { - return false; - } - } - private normalizeSessionEntry(entry: unknown): SessionEntry { - const value = (entry && typeof entry === "object") ? (entry as Record) : {}; + const value = entry && typeof entry === "object" ? (entry as Record) : {}; return { id: typeof value.id === "string" ? value.id : crypto.randomUUID(), summary: typeof value.summary === "string" ? value.summary : null, @@ -1823,11 +2712,13 @@ ${skillMd} toolCalls: Array.isArray(value.toolCalls) ? value.toolCalls : null, status: this.normalizeSessionStatus(value.status), failReason: typeof value.failReason === "string" ? value.failReason : null, - usage: value.usage ?? null, + usage: (value.usage as ModelUsage) ?? null, + usagePerModel: this.normalizeUsagePerModel(value), activeTokens: typeof value.activeTokens === "number" ? value.activeTokens : 0, createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(), updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), - processes: this.deserializeProcesses(value.processes) + processes: this.deserializeProcesses(value.processes), + askPermissions: normalizeAskPermissions(value.askPermissions), }; } @@ -1838,18 +2729,37 @@ ${skillMd} status === "processing" || status === "waiting_for_user" || status === "completed" || - status === "interrupted" + status === "interrupted" || + status === "ask_permission" || + status === "permission_denied" ) { return status; } return "pending"; } - private deserializeProcesses(value: unknown): Map | null { + private normalizeUsagePerModel(entry: Record): Record | null { + if (!Object.prototype.hasOwnProperty.call(entry, "usagePerModel")) { + return null; + } + if (!isUsageRecord(entry.usagePerModel)) { + return null; + } + const usagePerModel: Record = {}; + for (const [model, usage] of Object.entries(entry.usagePerModel)) { + if (!model || !isUsageRecord(usage)) { + continue; + } + usagePerModel[model] = usage as ModelUsage; + } + return usagePerModel; + } + + private deserializeProcesses(value: unknown): Map | null { if (!value || typeof value !== "object") { return null; } - const processes = new Map(); + const processes = new Map(); for (const [pid, entry] of Object.entries(value as Record)) { if (!pid) { continue; @@ -1858,20 +2768,34 @@ ${skillMd} // Backward compatibility for old format where just stored start time processes.set(pid, { startTime: entry, command: "Running process..." }); } else if (typeof entry === "object" && entry !== null) { - const obj = entry as { startTime?: unknown; command?: unknown }; + const obj = entry as { + startTime?: unknown; + command?: unknown; + timeoutMs?: unknown; + deadlineAt?: unknown; + timedOut?: unknown; + }; const startTime = typeof obj.startTime === "string" ? obj.startTime : new Date().toISOString(); const command = typeof obj.command === "string" ? obj.command : "Running process..."; - processes.set(pid, { startTime, command }); + processes.set(pid, { + startTime, + command, + timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : undefined, + deadlineAt: typeof obj.deadlineAt === "string" ? obj.deadlineAt : undefined, + timedOut: typeof obj.timedOut === "boolean" ? obj.timedOut : undefined, + }); } } return processes.size > 0 ? processes : null; } - private serializeProcesses(processes: Map | null): Record | null { + private serializeProcesses( + processes: Map | null + ): Record | null { if (!processes || processes.size === 0) { return null; } - const serialized: Record = {}; + const serialized: Record = {}; for (const [pid, entry] of processes.entries()) { serialized[pid] = entry; } diff --git a/src/settings.ts b/src/settings.ts index 39fe8cd..e0b1776 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,69 +1,372 @@ -import { defaultsToThinkingMode } from "./model-capabilities"; +import { defaultsToThinkingMode } from "./common/model-capabilities"; -export type DeepcodingEnv = { +export type DeepcodingEnv = Record & { MODEL?: string; BASE_URL?: string; API_KEY?: string; - THINKING?: string; + THINKING_ENABLED?: string; + REASONING_EFFORT?: string; + DEBUG_LOG_ENABLED?: string; }; export type ReasoningEffort = "high" | "max"; +export type McpServerConfig = { + command: string; + args?: string[]; + env?: Record; +}; + +export type PermissionScope = + | "read-in-cwd" + | "read-out-cwd" + | "write-in-cwd" + | "write-out-cwd" + | "delete-in-cwd" + | "delete-out-cwd" + | "query-git-log" + | "mutate-git-log" + | "network" + | "mcp"; + +export type PermissionDefaultMode = "allowAll" | "askAll"; + +export type PermissionSettings = { + allow?: PermissionScope[]; + deny?: PermissionScope[]; + ask?: PermissionScope[]; + defaultMode?: PermissionDefaultMode; +}; + export type DeepcodingSettings = { env?: DeepcodingEnv; + model?: string; thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; + debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + mcpServers?: Record; + permissions?: PermissionSettings; }; export type ResolvedDeepcodingSettings = { + env: Record; apiKey?: string; baseURL: string; model: string; thinkingEnabled: boolean; reasoningEffort: ReasoningEffort; + debugLogEnabled: boolean; notify?: string; webSearchTool?: string; + mcpServers?: Record; + permissions: Required; +}; + +export type ModelConfigSelection = { + model: string; + thinkingEnabled: boolean; + reasoningEffort: ReasoningEffort; }; -function resolveReasoningEffort(value: unknown): ReasoningEffort { - return value === "high" || value === "max" ? value : "max"; +export type SettingsProcessEnv = Record; + +function resolveReasoningEffort(value: unknown): ReasoningEffort | undefined { + return value === "high" || value === "max" ? value : undefined; } -function resolveThinkingEnabled( - settings: DeepcodingSettings | null | undefined, - model: string -): boolean { - if (typeof settings?.thinkingEnabled === "boolean") { - return settings.thinkingEnabled; +function parseBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; } - const legacyThinking = settings?.env?.THINKING; - if (typeof legacyThinking === "string" && legacyThinking.trim()) { - return legacyThinking.trim().toLowerCase() === "enabled"; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "enabled", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "disabled", "no", "off"].includes(normalized)) { + return false; } + return undefined; +} - return defaultsToThinkingMode(model); +function trimString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; } -export function resolveSettings( - settings: DeepcodingSettings | null | undefined, - defaults: { model: string; baseURL: string } +const VALID_PERMISSION_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +function normalizePermissionList(value: unknown): PermissionScope[] { + if (!Array.isArray(value)) { + return []; + } + const result: PermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !VALID_PERMISSION_SCOPES.has(item as PermissionScope)) { + continue; + } + const scope = item as PermissionScope; + if (!result.includes(scope)) { + result.push(scope); + } + } + return result; +} + +function mergePermissionLists(...lists: Array): PermissionScope[] { + const result: PermissionScope[] = []; + for (const list of lists) { + for (const scope of list ?? []) { + if (!result.includes(scope)) { + result.push(scope); + } + } + } + return result; +} + +function normalizePermissionDefaultMode(value: unknown): PermissionDefaultMode | undefined { + return value === "allowAll" || value === "askAll" ? value : undefined; +} + +function normalizePermissions(settings: PermissionSettings | null | undefined): Required { + return { + allow: normalizePermissionList(settings?.allow), + deny: normalizePermissionList(settings?.deny), + ask: normalizePermissionList(settings?.ask), + defaultMode: normalizePermissionDefaultMode(settings?.defaultMode) ?? "allowAll", + }; +} + +function mergePermissions( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): Required { + const userPermissions = normalizePermissions(userSettings?.permissions); + const projectPermissions = normalizePermissions(projectSettings?.permissions); + return { + allow: mergePermissionLists(userPermissions.allow, projectPermissions.allow), + deny: mergePermissionLists(userPermissions.deny, projectPermissions.deny), + ask: mergePermissionLists(userPermissions.ask, projectPermissions.ask), + defaultMode: projectSettings?.permissions + ? projectPermissions.defaultMode + : userSettings?.permissions + ? userPermissions.defaultMode + : "allowAll", + }; +} + +function normalizeEnv(env: DeepcodingSettings["env"]): Record { + const result: Record = {}; + if (!env) { + return result; + } + + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; +} + +export function collectDeepcodeEnv(processEnv: SettingsProcessEnv = process.env): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(processEnv)) { + if (!key.startsWith("DEEPCODE_") || typeof value !== "string") { + continue; + } + const strippedKey = key.slice("DEEPCODE_".length); + if (strippedKey) { + result[strippedKey] = value; + } + } + return result; +} + +function extractMcpEnv(env: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (!key.startsWith("MCP_")) { + continue; + } + const strippedKey = key.slice("MCP_".length); + if (strippedKey) { + result[strippedKey] = value; + } + } + return result; +} + +function mergeMcpServers( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + userEnv: Record, + projectEnv: Record, + systemEnv: Record +): Record | undefined { + const userServers = userSettings?.mcpServers ?? {}; + const projectServers = projectSettings?.mcpServers ?? {}; + const serverNames = new Set([...Object.keys(userServers), ...Object.keys(projectServers)]); + if (serverNames.size === 0) { + return undefined; + } + + const userMcpEnv = extractMcpEnv(userEnv); + const projectMcpEnv = extractMcpEnv(projectEnv); + const systemMcpEnv = extractMcpEnv(systemEnv); + const merged: Record = {}; + + for (const name of serverNames) { + const userConfig = userServers[name]; + const projectConfig = projectServers[name]; + const command = projectConfig?.command ?? userConfig?.command; + if (!command) { + continue; + } + + const env = { + ...userEnv, + ...(userConfig?.env ?? {}), + ...userMcpEnv, + ...projectEnv, + ...(projectConfig?.env ?? {}), + ...projectMcpEnv, + ...systemEnv, + ...systemMcpEnv, + }; + const config: McpServerConfig = { + command, + args: projectConfig?.args ?? userConfig?.args, + }; + if (Object.keys(env).length > 0) { + config.env = env; + } + merged[name] = config; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export function resolveSettingsSources( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + defaults: { model: string; baseURL: string }, + processEnv: SettingsProcessEnv = process.env ): ResolvedDeepcodingSettings { - const env = settings?.env ?? {}; - const model = env.MODEL?.trim() || defaults.model; - const notify = typeof settings?.notify === "string" ? settings.notify.trim() : ""; + const userEnv = normalizeEnv(userSettings?.env); + const projectEnv = normalizeEnv(projectSettings?.env); + const systemEnv = collectDeepcodeEnv(processEnv); + const env = { + ...userEnv, + ...projectEnv, + ...systemEnv, + }; + + const model = + trimString(systemEnv.MODEL) || + trimString(projectSettings?.model) || + trimString(projectEnv.MODEL) || + trimString(userSettings?.model) || + trimString(userEnv.MODEL) || + defaults.model; + + const thinkingEnabled = + parseBoolean(systemEnv.THINKING_ENABLED) ?? + parseBoolean(projectSettings?.thinkingEnabled) ?? + parseBoolean(projectEnv.THINKING_ENABLED) ?? + parseBoolean(userSettings?.thinkingEnabled) ?? + parseBoolean(userEnv.THINKING_ENABLED) ?? + defaultsToThinkingMode(model); + + const reasoningEffort = + resolveReasoningEffort(systemEnv.REASONING_EFFORT) ?? + resolveReasoningEffort(projectSettings?.reasoningEffort) ?? + resolveReasoningEffort(projectEnv.REASONING_EFFORT) ?? + resolveReasoningEffort(userSettings?.reasoningEffort) ?? + resolveReasoningEffort(userEnv.REASONING_EFFORT) ?? + "max"; + + const debugLogEnabled = + parseBoolean(systemEnv.DEBUG_LOG_ENABLED) ?? + parseBoolean(projectSettings?.debugLogEnabled) ?? + parseBoolean(projectEnv.DEBUG_LOG_ENABLED) ?? + parseBoolean(userSettings?.debugLogEnabled) ?? + parseBoolean(userEnv.DEBUG_LOG_ENABLED) ?? + false; + + const notify = + trimString(systemEnv.NOTIFY) || trimString(projectSettings?.notify) || trimString(userSettings?.notify) || ""; const webSearchTool = - typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; + trimString(systemEnv.WEB_SEARCH_TOOL) || + trimString(projectSettings?.webSearchTool) || + trimString(userSettings?.webSearchTool) || + ""; return { - apiKey: env.API_KEY?.trim(), - baseURL: env.BASE_URL?.trim() || defaults.baseURL, + env, + apiKey: trimString(env.API_KEY) || undefined, + baseURL: trimString(env.BASE_URL) || defaults.baseURL, model, - thinkingEnabled: resolveThinkingEnabled(settings, model), - reasoningEffort: resolveReasoningEffort(settings?.reasoningEffort), + thinkingEnabled, + reasoningEffort, + debugLogEnabled, notify: notify || undefined, - webSearchTool: webSearchTool || undefined + webSearchTool: webSearchTool || undefined, + mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), + permissions: mergePermissions(userSettings, projectSettings), }; } + +export function resolveSettings( + settings: DeepcodingSettings | null | undefined, + defaults: { model: string; baseURL: string }, + processEnv: SettingsProcessEnv = process.env +): ResolvedDeepcodingSettings { + return resolveSettingsSources(settings, null, defaults, processEnv); +} + +export function modelConfigKey(config: Pick): string { + return config.thinkingEnabled ? `thinking:${config.reasoningEffort}` : "thinking:none"; +} + +export function applyModelConfigSelection( + settings: DeepcodingSettings | null | undefined, + current: ModelConfigSelection, + selected: ModelConfigSelection +): { settings: DeepcodingSettings; changed: boolean } { + const changed = selected.model !== current.model || modelConfigKey(selected) !== modelConfigKey(current); + const next: DeepcodingSettings = { ...(settings ?? {}) }; + + if (!changed) { + return { settings: next, changed: false }; + } + + if (selected.model !== current.model || Object.prototype.hasOwnProperty.call(next, "model")) { + next.model = selected.model; + } else { + delete next.model; + } + + next.thinkingEnabled = selected.thinkingEnabled; + if (selected.thinkingEnabled) { + next.reasoningEffort = selected.reasoningEffort; + } + + return { settings: next, changed: true }; +} diff --git a/src/tests/askUserQuestion.test.ts b/src/tests/askUserQuestion.test.ts index fc926fd..f754351 100644 --- a/src/tests/askUserQuestion.test.ts +++ b/src/tests/askUserQuestion.test.ts @@ -1,10 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - findPendingAskUserQuestion, - formatAskUserQuestionAnswers, - formatAskUserQuestionDecline -} from "../ui/askUserQuestion"; +import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, formatAskUserQuestionDecline } from "../ui"; import type { SessionMessage } from "../session"; function message(content: unknown): SessionMessage { @@ -19,49 +15,83 @@ function message(content: unknown): SessionMessage { compacted: false, visible: true, createTime: now, - updateTime: now + updateTime: now, }; } test("findPendingAskUserQuestion returns latest pending AskUserQuestion tool message", () => { - const pending = findPendingAskUserQuestion([ - message({ ok: true, name: "read" }), - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [ - { - question: "Which package manager should we use?", - options: [ - { label: "npm", description: "Use package-lock.json." }, - { label: "yarn" } - ] - } - ] - } - }) - ], "waiting_for_user"); + const pending = findPendingAskUserQuestion( + [ + message({ ok: true, name: "read" }), + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [ + { + question: "Which package manager should we use?", + options: [{ label: "npm", description: "Use package-lock.json." }, { label: "yarn" }], + }, + ], + }, + }), + ], + "waiting_for_user" + ); assert.equal(pending?.messageId, "tool-message"); assert.equal(pending?.questions[0]?.question, "Which package manager should we use?"); assert.equal(pending?.questions[0]?.options[0]?.description, "Use package-lock.json."); }); +test("findPendingAskUserQuestion preserves multiple pending questions in order", () => { + const pending = findPendingAskUserQuestion( + [ + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [ + { + question: "Use default description?", + options: [{ label: "Yes" }, { label: "Custom" }], + }, + { + question: "Where should the project be created?", + options: [{ label: "Current directory" }, { label: "Custom path" }], + }, + ], + }, + }), + ], + "waiting_for_user" + ); + + assert.deepEqual( + pending?.questions.map((question) => question.question), + ["Use default description?", "Where should the project be created?"] + ); +}); + test("findPendingAskUserQuestion ignores questions unless session waits for user", () => { - const pending = findPendingAskUserQuestion([ - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] - } - }) - ], "processing"); + const pending = findPendingAskUserQuestion( + [ + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [{ question: "Continue?", options: [{ label: "Yes" }] }], + }, + }), + ], + "processing" + ); assert.equal(pending, null); }); @@ -70,9 +100,9 @@ test("formatAskUserQuestionAnswers creates model-readable answer text", () => { assert.equal( formatAskUserQuestionAnswers({ "Which package manager?": "yarn", - "Any notes?": "Use the existing lockfile" + "Any notes?": "Use the existing lockfile", }), - "User has answered your questions: \"Which package manager?\"=\"yarn\", \"Any notes?\"=\"Use the existing lockfile\". You can now continue with the user's answers in mind." + 'User has answered your questions: "Which package manager?"="yarn", "Any notes?"="Use the existing lockfile". You can now continue with the user\'s answers in mind.' ); }); diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts index 5138935..dbe9ff9 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -4,6 +4,9 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type ClipboardModule = typeof import("../ui/clipboard"); + const ORIGINAL_PATH = process.env.PATH; const ORIGINAL_PLATFORM = process.platform; @@ -27,52 +30,50 @@ function withPlatform(platform: NodeJS.Platform, fn: () => T): T { test("readClipboardImage returns null when no clipboard helpers are installed", async () => { // Reload module so it picks up the patched PATH at spawn time. - const modulePath = require.resolve("../ui/clipboard"); - delete require.cache[modulePath]; - const { readClipboardImage } = require("../ui/clipboard") as typeof import("../ui/clipboard"); + const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; const result = withCleanPath(() => readClipboardImage()); assert.equal(result, null); }); -test("readClipboardImage uses osascript fallback on macOS when pngpaste is missing", async () => { - const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); - try { - fs.writeFileSync( - path.join(binDir, "pngpaste"), - "#!/bin/sh\nexit 1\n", - { mode: 0o755 } - ); - fs.writeFileSync( - path.join(binDir, "osascript"), - [ - "#!/bin/sh", - "for arg in \"$@\"; do", - " case \"$arg\" in", - " *'open for access POSIX file " + '"' + "'*)", - " path_part=${arg#*POSIX file \\\"}", - " out_path=${path_part%%\\\"*}", - " printf fakepng > \"$out_path\"", - " exit 0", - " ;;", - " esac", - "done", - "exit 1", - "" - ].join("\n"), - { mode: 0o755 } - ); +test( + "readClipboardImage uses osascript fallback on macOS when pngpaste is missing", + { skip: process.platform === "win32" }, + async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); + try { + fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(binDir, "osascript"), + [ + "#!/bin/sh", + 'for arg in "$@"; do', + ' case "$arg" in', + " *'open for access POSIX file " + '"' + "'*)", + ' path_part=${arg#*POSIX file \\"}', + ' out_path=${path_part%%\\"*}', + ' printf fakepng > "$out_path"', + " exit 0", + " ;;", + " esac", + "done", + "exit 1", + "", + ].join("\n"), + { mode: 0o755 } + ); - const modulePath = require.resolve("../ui/clipboard"); - delete require.cache[modulePath]; - const { readClipboardImage } = require("../ui/clipboard") as typeof import("../ui/clipboard"); + const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; - process.env.PATH = binDir; - const result = withPlatform("darwin", () => readClipboardImage()); - assert.equal(result?.mimeType, "image/png"); - assert.equal(result?.dataUrl, `data:image/png;base64,${Buffer.from("fakepng").toString("base64")}`); - } finally { - process.env.PATH = ORIGINAL_PATH; - Object.defineProperty(process, "platform", { value: ORIGINAL_PLATFORM }); - fs.rmSync(binDir, { recursive: true, force: true }); + process.env.PATH = binDir; + const result = withPlatform("darwin", () => readClipboardImage()); + assert.equal(result?.mimeType, "image/png"); + assert.equal(result?.dataUrl, `data:image/png;base64,${Buffer.from("fakepng").toString("base64")}`); + } finally { + process.env.PATH = ORIGINAL_PATH; + Object.defineProperty(process, "platform", { value: ORIGINAL_PLATFORM }); + fs.rmSync(binDir, { recursive: true, force: true }); + } } -}); +); diff --git a/src/tests/debug-logger.test.ts b/src/tests/debug-logger.test.ts new file mode 100644 index 0000000..7b1aad4 --- /dev/null +++ b/src/tests/debug-logger.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { getDebugLogPath, logOpenAIChatCompletionDebug } from "../common/debug-logger"; + +test("debug logger appends full entries without rotation", () => { + const originalHome = process.env.HOME; + const home = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-debug-log-home-")); + process.env.HOME = home; + try { + for (let index = 0; index < 25; index += 1) { + logOpenAIChatCompletionDebug({ + timestamp: "2026-01-01T00:00:00.000Z", + location: "test.location", + requestId: `request-${index}`, + model: "test-model", + request: { + model: "test-model", + messages: [{ role: "user", content: `full request content ${index}` }], + }, + response: { + choices: [{ message: { content: `full response content ${index}` } }], + }, + }); + } + + const raw = fs.readFileSync(getDebugLogPath(), "utf8"); + const lines = raw.trim().split("\n"); + assert.equal(lines.length, 25); + + const first = JSON.parse(lines[0]) as Record; + const last = JSON.parse(lines[24]) as Record; + assert.equal(first.requestId, "request-0"); + assert.equal(first.request.messages[0].content, "full request content 0"); + assert.equal(last.requestId, "request-24"); + assert.equal(last.response.choices[0].message.content, "full response content 24"); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } +}); diff --git a/src/tests/dropdownMenu.test.ts b/src/tests/dropdownMenu.test.ts new file mode 100644 index 0000000..3e4e3ef --- /dev/null +++ b/src/tests/dropdownMenu.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { calculateVisibleStart } from "../ui/DropdownMenu"; + +test("calculateVisibleStart centers active item when possible", () => { + // 10 items, max 5 visible, active index 4 (middle) + // Should show items 2-6 (start at 2) + const start = calculateVisibleStart(4, 10, 5); + assert.equal(start, 2); +}); + +test("calculateVisibleStart handles active item at the beginning", () => { + // 10 items, max 5 visible, active index 0 + // Should show items 0-4 (start at 0) + const start = calculateVisibleStart(0, 10, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles active item at the end", () => { + // 10 items, max 5 visible, active index 9 (last) + // Should show items 5-9 (start at 5) + const start = calculateVisibleStart(9, 10, 5); + assert.equal(start, 5); +}); + +test("calculateVisibleStart handles fewer items than maxVisible", () => { + // 3 items, max 5 visible, active index 1 + // Should show all items (start at 0) + const start = calculateVisibleStart(1, 3, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles single item", () => { + // 1 item, max 5 visible, active index 0 + // Should start at 0 + const start = calculateVisibleStart(0, 1, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles empty list", () => { + // 0 items, max 5 visible, active index 0 + // Should start at 0 + const start = calculateVisibleStart(0, 0, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex near start with odd maxVisible", () => { + // 10 items, max 7 visible (odd), active index 2 + // floor((7-1)/2) = 3, so 2-3 = -1, clamped to 0 + const start = calculateVisibleStart(2, 10, 7); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex near start with even maxVisible", () => { + // 10 items, max 6 visible (even), active index 2 + // floor((6-1)/2) = 2, so 2-2 = 0 + const start = calculateVisibleStart(2, 10, 6); + assert.equal(start, 0); +}); + +test("calculateVisibleStart keeps active item centered in middle range", () => { + // 20 items, max 5 visible, active index 10 + // floor((5-1)/2) = 2, so 10-2 = 8 + const start = calculateVisibleStart(10, 20, 5); + assert.equal(start, 8); +}); + +test("calculateVisibleStart handles activeIndex at exact boundary", () => { + // 10 items, max 5 visible, active index 2 (boundary where centering starts) + // floor((5-1)/2) = 2, so 2-2 = 0 + const start = calculateVisibleStart(2, 10, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex just after boundary", () => { + // 10 items, max 5 visible, active index 3 + // floor((5-1)/2) = 2, so 3-2 = 1 + const start = calculateVisibleStart(3, 10, 5); + assert.equal(start, 1); +}); + +test("calculateVisibleStart handles large maxVisible", () => { + // 10 items, max 100 visible, active index 5 + // Should show all items (start at 0) + const start = calculateVisibleStart(5, 10, 100); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex equal to totalItems", () => { + // 10 items, max 5 visible, active index 10 (out of bounds) + // floor((5-1)/2) = 2, so 10-2 = 8, clamped to 5 (10-5) + const start = calculateVisibleStart(10, 10, 5); + assert.equal(start, 5); +}); + +test("calculateVisibleStart with maxVisible of 1", () => { + // 5 items, max 1 visible, active index 2 + // floor((1-1)/2) = 0, so 2-0 = 2, clamped to 4 (5-1) + const start = calculateVisibleStart(2, 5, 1); + assert.equal(start, 2); +}); + +test("calculateVisibleStart with maxVisible of 1 at end", () => { + // 5 items, max 1 visible, active index 4 (last) + // floor((1-1)/2) = 0, so 4-0 = 4, clamped to 4 (5-1) + const start = calculateVisibleStart(4, 5, 1); + assert.equal(start, 4); +}); + +test("calculateVisibleStart scrolling behavior - moving down", () => { + // Simulate scrolling through a list + // 10 items, max 5 visible + + // Start at index 0 + assert.equal(calculateVisibleStart(0, 10, 5), 0); + + // Move to index 2 (still centered) + assert.equal(calculateVisibleStart(2, 10, 5), 0); + + // Move to index 5 (window should scroll) + assert.equal(calculateVisibleStart(5, 10, 5), 3); + + // Move to index 8 (near end) + assert.equal(calculateVisibleStart(8, 10, 5), 5); + + // Move to index 9 (at end) + assert.equal(calculateVisibleStart(9, 10, 5), 5); +}); + +test("calculateVisibleStart scrolling behavior - moving up", () => { + // Simulate scrolling up through a list + // 10 items, max 5 visible + + // Start at index 9 (end) + assert.equal(calculateVisibleStart(9, 10, 5), 5); + + // Move to index 6 + assert.equal(calculateVisibleStart(6, 10, 5), 4); + + // Move to index 4 (window should scroll up) + assert.equal(calculateVisibleStart(4, 10, 5), 2); + + // Move to index 1 (near start) + assert.equal(calculateVisibleStart(1, 10, 5), 0); + + // Move to index 0 (at start) + assert.equal(calculateVisibleStart(0, 10, 5), 0); +}); diff --git a/src/tests/exitSummary.test.ts b/src/tests/exitSummary.test.ts new file mode 100644 index 0000000..5ea4b57 --- /dev/null +++ b/src/tests/exitSummary.test.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildExitSummaryText } from "../ui"; +import type { ModelUsage, SessionEntry } from "../session"; + +const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); + +test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null, { + "mimo-v2.5-pro": { + prompt_tokens: 11_966, + completion_tokens: 236, + total_tokens: 12_202, + prompt_tokens_details: { cached_tokens: 11_776 }, + completion_tokens_details: { reasoning_tokens: 144 }, + total_reqs: 2, + }, + }), + }) + ); + + assert.match(summary, /Goodbye!/); + assert.match(summary, /╭─+╮/); + assert.match(summary, /╰─+╯/); + assert.match(summary, /Model Usage/); + assert.match(summary, /Cached Tokens/); + assert.match(summary, /mimo-v2\.5-pro\s+2\s+11,966\s+236\s+11,776/); + assert.doesNotMatch(summary, /Agent powering down/); + assert.doesNotMatch(summary, /Interaction Summary/); + assert.doesNotMatch(summary, /Context Window/); + assert.doesNotMatch(summary, /Savings Highlight/); + assert.doesNotMatch(summary, /Reasoning Tokens/); +}); + +test("buildExitSummaryText shows all usagePerModel rows sorted by request count", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession( + { + prompt_tokens: 999, + completion_tokens: 999, + total_tokens: 1_998, + }, + { + "deepseek-v4-pro": { + prompt_tokens: 100, + completion_tokens: 10, + total_tokens: 110, + total_reqs: 1, + }, + "deepseek-v4-flash": { + prompt_tokens: 300, + completion_tokens: 30, + total_tokens: 330, + prompt_cache_hit_tokens: 111, + total_reqs: 3, + }, + } + ), + }) + ); + + const flashIndex = summary.indexOf("deepseek-v4-flash"); + const proIndex = summary.indexOf("deepseek-v4-pro"); + + assert.notEqual(flashIndex, -1); + assert.notEqual(proIndex, -1); + assert.ok(flashIndex < proIndex); + assert.match(summary, /deepseek-v4-flash\s+3\s+300\s+30\s+111/); + assert.match(summary, /deepseek-v4-pro\s+1\s+100\s+10\s+0/); + assert.doesNotMatch(summary, /999/); +}); + +test("buildExitSummaryText does not derive usage rows from legacy aggregate usage", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession({ + prompt_tokens: 11_966, + completion_tokens: 236, + total_tokens: 12_202, + total_reqs: 2, + }), + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /Model Usage/); + assert.doesNotMatch(summary, /11,966/); +}); + +function buildSession(usage: ModelUsage | null, usagePerModel: Record | null = null): SessionEntry { + return { + id: "session-1", + summary: null, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage, + usagePerModel, + activeTokens: 0, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:01.000Z", + processes: null, + }; +} diff --git a/src/tests/fileMentions.test.ts b/src/tests/fileMentions.test.ts new file mode 100644 index 0000000..b382eee --- /dev/null +++ b/src/tests/fileMentions.test.ts @@ -0,0 +1,272 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + filterFileMentionItems, + formatFileMentionPath, + getCurrentFileMentionToken, + replaceCurrentFileMentionToken, + scanFileMentionItems, + type FileMentionItem, +} from "../ui/fileMentions"; + +test("getCurrentFileMentionToken detects bare @file tokens under the cursor", () => { + assert.deepEqual(getCurrentFileMentionToken({ text: "review @src/app.ts please", cursor: 10 }), { + query: "src/app.ts", + start: 7, + end: 18, + quoted: false, + }); + assert.deepEqual(getCurrentFileMentionToken({ text: "@", cursor: 1 }), { + query: "", + start: 0, + end: 1, + quoted: false, + }); + assert.equal(getCurrentFileMentionToken({ text: "foo@bar", cursor: 7 }), null); +}); + +test("getCurrentFileMentionToken supports quoted paths with spaces", () => { + assert.deepEqual(getCurrentFileMentionToken({ text: 'open @"docs/my file.md"', cursor: 22 }), { + query: "docs/my file.md", + start: 5, + end: 23, + quoted: true, + }); + assert.deepEqual(getCurrentFileMentionToken({ text: 'open @"docs/my', cursor: 14 }), { + query: "docs/my", + start: 5, + end: 14, + quoted: true, + }); + assert.equal(getCurrentFileMentionToken({ text: 'open @"docs/my file.md" now', cursor: 24 }), null); +}); + +test("formatFileMentionPath quotes only paths that need it", () => { + assert.equal(formatFileMentionPath("src/App.tsx"), "@src/App.tsx"); + assert.equal(formatFileMentionPath("docs/my file.md"), '@"docs/my file.md"'); + assert.equal(formatFileMentionPath('docs/a"b.md'), '@"docs/a\\"b.md"'); +}); + +test("replaceCurrentFileMentionToken inserts a trailing-space mention", () => { + const state = { text: "read @sr then", cursor: 8 }; + const token = getCurrentFileMentionToken(state); + assert.ok(token); + assert.deepEqual(replaceCurrentFileMentionToken(state, token, "src/index.ts"), { + text: "read @src/index.ts then", + cursor: 19, + }); + + const quotedState = { text: 'read @"doc', cursor: 10 }; + const quotedToken = getCurrentFileMentionToken(quotedState); + assert.ok(quotedToken); + assert.deepEqual(replaceCurrentFileMentionToken(quotedState, quotedToken, "docs/my file.md"), { + text: 'read @"docs/my file.md" ', + cursor: 24, + }); +}); + +test("filterFileMentionItems prioritizes prefix and basename matches", () => { + const items: FileMentionItem[] = [ + { path: "src/PromptInput.tsx", type: "file" }, + { path: "docs/prompt guide.md", type: "file" }, + { path: "templates/prompts/init.md", type: "file" }, + ]; + + assert.deepEqual( + filterFileMentionItems(items, "prompt").map((item) => item.path), + ["docs/prompt guide.md", "src/PromptInput.tsx", "templates/prompts/init.md"] + ); +}); + +test("scanFileMentionItems returns relative slash-separated files and directories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src", "index.ts"), ""); + fs.mkdirSync(path.join(root, "node_modules")); + fs.writeFileSync(path.join(root, "node_modules", "ignored.js"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["node_modules/", "node_modules/ignored.js", "src/", "src/index.ts"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems respects project gitignore patterns inside git repositories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.mkdirSync(path.join(root, ".mypy_cache"), { recursive: true }); + fs.writeFileSync(path.join(root, ".mypy_cache", "ignored.json"), ""); + fs.mkdirSync(path.join(root, "tmp")); + fs.writeFileSync(path.join(root, "tmp", "ignored.txt"), ""); + fs.mkdirSync(path.join(root, "docs")); + fs.writeFileSync(path.join(root, "docs", "guide.md"), ""); + fs.writeFileSync(path.join(root, ".gitignore"), ".mypy_cache/\ntmp/\n"); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["docs/", "docs/guide.md", ".gitignore"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems ignores gitignore files outside git repositories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, "tmp")); + fs.writeFileSync(path.join(root, "tmp", "visible.txt"), ""); + fs.writeFileSync(path.join(root, ".gitignore"), "tmp/\n"); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["tmp/", "tmp/visible.txt", ".gitignore"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems applies parent and nested ignore files", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, ".gitignore"), "ignored-from-parent/\n"); + fs.mkdirSync(path.join(root, "sub", "ignored-from-parent"), { recursive: true }); + fs.writeFileSync(path.join(root, "sub", "ignored-from-parent", "hidden.txt"), ""); + fs.mkdirSync(path.join(root, "sub", "nested", "ignored-from-nested"), { recursive: true }); + fs.writeFileSync(path.join(root, "sub", "nested", ".gitignore"), "ignored-from-nested/\n"); + fs.writeFileSync(path.join(root, "sub", "nested", "ignored-from-nested", "hidden.txt"), ""); + fs.writeFileSync(path.join(root, "sub", "nested", "visible.txt"), ""); + + assert.deepEqual( + scanFileMentionItems(path.join(root, "sub")).map((item) => item.path), + ["nested/", "nested/.gitignore", "nested/visible.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems applies git info exclude at the repository root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git", "info"), { recursive: true }); + fs.writeFileSync(path.join(root, ".git", "info", "exclude"), "secret.txt\n"); + fs.writeFileSync(path.join(root, "secret.txt"), ""); + fs.writeFileSync(path.join(root, "visible.txt"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["visible.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems applies .ignore files outside git repositories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.writeFileSync(path.join(root, ".ignore"), "ignored.txt\n"); + fs.writeFileSync(path.join(root, "ignored.txt"), ""); + fs.writeFileSync(path.join(root, "visible.txt"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".ignore", "visible.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems honors gitignore negation patterns", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, ".gitignore"), "*.log\n!important.log\n"); + fs.writeFileSync(path.join(root, "debug.log"), ""); + fs.writeFileSync(path.join(root, "important.log"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".gitignore", "important.log"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems includes hidden entries except the .git directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, ".env"), ""); + fs.mkdirSync(path.join(root, ".config")); + fs.writeFileSync(path.join(root, ".config", "settings.json"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".config/", ".config/settings.json", ".env"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems sees files created after an earlier scan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + assert.deepEqual(scanFileMentionItems(root), []); + + fs.writeFileSync(path.join(root, "index.html"), ""); + + assert.deepEqual(scanFileMentionItems(root), [{ path: "index.html", type: "file" }]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems follows symlinked files", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.writeFileSync(path.join(root, "source.txt"), ""); + try { + fs.symlinkSync(path.join(root, "source.txt"), path.join(root, "alias.txt")); + } catch { + t.skip("symlink creation is not available in this environment"); + return; + } + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + ["alias.txt", "source.txt"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("filterFileMentionItems returns newly scanned files for @ mention queries", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + fs.writeFileSync(path.join(root, "index.html"), ""); + const items = scanFileMentionItems(root); + + assert.deepEqual( + filterFileMentionItems(items, "index").map((item) => item.path), + ["index.html"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/tests/loadingText.test.ts b/src/tests/loadingText.test.ts index b15187a..784fe46 100644 --- a/src/tests/loadingText.test.ts +++ b/src/tests/loadingText.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildLoadingText } from "../ui/loadingText"; +import { buildLoadingText } from "../ui"; test("buildLoadingText returns plain Thinking... when no progress", () => { assert.equal(buildLoadingText({ progress: null, now: Date.now() }), "Thinking..."); @@ -9,9 +9,7 @@ test("buildLoadingText returns plain Thinking... when no progress", () => { test("buildLoadingText shows running process elapsed time before thinking progress", () => { const startedAt = "2026-04-28T00:00:00.000Z"; const now = Date.parse(startedAt) + 5_750; - const processes = new Map([ - ["123", { startTime: startedAt, command: "yarn install" }] - ]); + const processes = new Map([["123", { startTime: startedAt, command: "yarn install" }]]); const text = buildLoadingText({ processes, progress: { @@ -19,9 +17,9 @@ test("buildLoadingText shows running process elapsed time before thinking progre startedAt, estimatedTokens: 850, formattedTokens: "850", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "(5s) yarn install"); }); @@ -29,13 +27,8 @@ test("buildLoadingText shows running process elapsed time before thinking progre test("buildLoadingText formats long-running process time with minutes", () => { const startedAt = "2026-04-28T00:00:00.000Z"; const now = Date.parse(startedAt) + 65_250; - const processes = new Map([ - ["web-search", { startTime: startedAt, command: "WebSearch: latest node release" }] - ]); - assert.equal( - buildLoadingText({ processes, progress: null, now }), - "(1m5s) WebSearch: latest node release" - ); + const processes = new Map([["web-search", { startTime: startedAt, command: "WebSearch: latest node release" }]]); + assert.equal(buildLoadingText({ processes, progress: null, now }), "(1m5s) WebSearch: latest node release"); }); test("buildLoadingText returns plain Thinking... while elapsed below 3s", () => { @@ -47,9 +40,9 @@ test("buildLoadingText returns plain Thinking... while elapsed below 3s", () => startedAt, estimatedTokens: 12, formattedTokens: "12", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "Thinking..."); }); @@ -63,9 +56,9 @@ test("buildLoadingText shows elapsed seconds and tokens once past the threshold" startedAt, estimatedTokens: 850, formattedTokens: "850", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "Thinking... (5s) · ↓ 850 tokens"); }); @@ -79,9 +72,9 @@ test("buildLoadingText falls back to '0' when formattedTokens is missing", () => startedAt, estimatedTokens: 0, formattedTokens: "", - phase: "update" + phase: "update", }, - now + now, }); assert.equal(text, "Thinking... (4s) · ↓ 0 tokens"); }); @@ -93,9 +86,9 @@ test("buildLoadingText falls back to Thinking... when timestamp is unparseable", startedAt: "not-a-date", estimatedTokens: 0, formattedTokens: "0", - phase: "update" + phase: "update", }, - now: Date.now() + now: Date.now(), }); assert.equal(text, "Thinking..."); }); diff --git a/src/tests/markdown.test.ts b/src/tests/markdown.test.ts index 28a2ed0..bc5d33c 100644 --- a/src/tests/markdown.test.ts +++ b/src/tests/markdown.test.ts @@ -1,12 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { renderMarkdown } from "../ui/markdown"; +import { renderMarkdown, renderMarkdownSegments } from "../ui"; function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex return text.replace(/\[[0-9;]*m/g, ""); } +function visualWidth(text: string): number { + let width = 0; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + width += + ch.length >= 2 || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xff00 && code <= 0xffe6) + ? 2 + : 1; + } + return width; +} + test("renderMarkdown returns empty string for empty input", () => { assert.equal(renderMarkdown(""), ""); }); @@ -39,3 +53,41 @@ test("renderMarkdown handles plain text unchanged in stripped form", () => { const result = stripAnsi(renderMarkdown(text)); assert.equal(result, text); }); + +test("renderMarkdownSegments renders CJK table cells within the requested width", () => { + const table = [ + "| 编号 | 状态 | 任务 | 备注 |", + "|---|---|---|---|", + "| 1 | ✅ | 写代码 | 这是一个很长很长的中文备注用于验证表格在终端宽度不足时是否能够自动换行而不是溢出 |", + ].join("\n"); + + const segment = renderMarkdownSegments(table, 60).find((item) => item.kind === "table"); + assert.ok(segment); + const lines = stripAnsi(segment.body).split("\n"); + assert.equal(lines[0].startsWith("┌"), true); + assert.equal(lines.at(-1)?.startsWith("└"), true); + assert.equal( + lines.every((line) => visualWidth(line) <= 60), + true + ); + assert.equal(lines.length > 4, true); +}); + +test("renderMarkdown preserves empty table cells", () => { + const result = stripAnsi(renderMarkdown("| A | B | C |\n|---|---|---|\n|x||z|", 80)); + const bodyRow = result.split("\n").find((line) => line.includes("x") && line.includes("z")); + assert.ok(bodyRow); + assert.equal((bodyRow.match(/│/g) ?? []).length, 4); +}); + +test("renderMarkdown keeps text separated from rendered table blocks", () => { + const result = stripAnsi(renderMarkdown("Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter", 40)); + assert.equal(result.includes("Before\n┌"), true); + assert.equal(result.includes("┘\nAfter"), true); +}); + +test("renderMarkdown does not render tables inside code fences", () => { + const result = stripAnsi(renderMarkdown("```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```", 40)); + assert.equal(result.includes("| A | B |"), true); + assert.equal(result.includes("┌"), false); +}); diff --git a/src/tests/mcp-client.test.ts b/src/tests/mcp-client.test.ts new file mode 100644 index 0000000..e161aad --- /dev/null +++ b/src/tests/mcp-client.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createMcpSpawnSpec } from "../mcp/mcp-client"; + +test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => { + assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "darwin"), { + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + shell: false, + }); +}); + +test("createMcpSpawnSpec avoids Windows shell args for Node 24", () => { + assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "win32"), { + command: '"npx" "-y" "@playwright/mcp@latest"', + args: [], + shell: true, + windowsHide: true, + }); +}); + +test("createMcpSpawnSpec quotes Windows command paths and arguments", () => { + const spec = createMcpSpawnSpec( + String.raw`C:\Program Files\nodejs\node.exe`, + [String.raw`C:\tmp\mcp server.cjs`, 'a "quoted" value'], + "win32" + ); + + assert.equal( + spec.command, + String.raw`"C:\Program Files\nodejs\node.exe" "C:\tmp\mcp server.cjs" "a \"quoted\" value"` + ); + assert.deepEqual(spec.args, []); +}); diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index cab559e..b806dbd 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -1,22 +1,25 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { MessageView, parseDiffPreview } from "../ui/MessageView"; +import { parseDiffPreview } from "../ui"; +import { + buildThinkingSummary, + renderMessageToStdout, + getUpdatePlanPreviewLines, + parseToolPayload, +} from "../ui/components/MessageView/utils"; +import { RawMode } from "../ui/contexts"; import type { SessionMessage } from "../session"; +import type { ToolSummary } from "../ui/components/MessageView/types"; test("parseDiffPreview removes headers and classifies lines", () => { - const lines = parseDiffPreview([ - "--- a/file.txt", - "+++ b/file.txt", - "@@ -1,1 +1,1 @@", - " context", - "-old", - "+new" - ].join("\n")); + const lines = parseDiffPreview( + ["--- a/file.txt", "+++ b/file.txt", "@@ -1,1 +1,1 @@", " context", "-old", "+new"].join("\n") + ); assert.deepEqual(lines, [ { marker: " ", content: "context", kind: "context" }, { marker: "-", content: "old", kind: "removed" }, - { marker: "+", content: "new", kind: "added" } + { marker: "+", content: "new", kind: "added" }, ]); }); @@ -24,51 +27,243 @@ test("parseDiffPreview keeps nonstandard context lines", () => { const lines = parseDiffPreview("...\n+added"); assert.deepEqual(lines, [ { marker: " ", content: "...", kind: "context" }, - { marker: "+", content: "added", kind: "added" } + { marker: "+", content: "added", kind: "added" }, ]); }); test("MessageView summarizes thinking content across lines", () => { assert.equal( - getThinkingParams({ - content: "Plan:\n\nInspect the code and update tests" - }), + buildThinkingSummary("Plan:\n\nInspect the code and update tests", null, RawMode.Lite), "Plan: Inspect the code and update tests" ); }); -test("MessageView removes a trailing colon from thinking summaries", () => { - assert.equal(getThinkingParams({ content: "Planning:" }), "Planning"); +test("MessageView removes a trailing colon from thinking summary", () => { + assert.equal(buildThinkingSummary("Planning:", null, RawMode.Lite), "Planning"); }); -test("MessageView falls back to a reasoning placeholder for hidden reasoning content", () => { +test("MessageView falls back to a reasoning placeholder for hidden reasoning content in Lite mode", () => { assert.equal( - getThinkingParams({ - content: "", - messageParams: { reasoning_content: "hidden chain of thought" } - }), + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Lite), "(reasoning...)" ); }); -function getThinkingParams(overrides: Partial): string { - const view = MessageView({ message: buildAssistantMessage(overrides) }) as any; - return view.props.children.props.params; -} +test("MessageView shows full reasoning content in Normal/Raw mode", () => { + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.None), + "hidden chain of thought" + ); + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Raw), + "hidden chain of thought" + ); +}); + +// --- renderMessageToStdout tests --- -function buildAssistantMessage(overrides: Partial): SessionMessage { +function makeSessionMessage(overrides: Partial & Pick): SessionMessage { + const now = new Date().toISOString(); return { - id: "message-1", - sessionId: "session-1", + id: overrides.id ?? `test-${Math.random().toString(36).slice(2)}`, + sessionId: overrides.sessionId ?? "test-session", + role: overrides.role, + content: overrides.content ?? null, + visible: overrides.visible ?? true, + compacted: overrides.compacted ?? false, + createTime: overrides.createTime ?? now, + updateTime: overrides.updateTime ?? now, + contentParams: overrides.contentParams ?? null, + messageParams: overrides.messageParams ?? null, + meta: overrides.meta, + html: overrides.html, + }; +} + +test("renderMessageToStdout returns empty for invisible messages", () => { + const msg = makeSessionMessage({ role: "user", content: "hello", visible: false }); + assert.equal(renderMessageToStdout(msg, RawMode.Raw), ""); +}); + +test("renderMessageToStdout renders user messages with > prefix", () => { + const msg = makeSessionMessage({ role: "user", content: "fix the bug" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("> fix the bug")); +}); + +test("renderMessageToStdout shows (no content) for empty user messages", () => { + const msg = makeSessionMessage({ role: "user", content: "" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("(no content)")); +}); + +test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => { + const msg = makeSessionMessage({ role: "assistant", content: "Here is the fix" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✦")); + assert.ok(output.includes("Here is the fix")); +}); + +test("renderMessageToStdout renders assistant thinking messages with ✧ Thinking", () => { + const msg = makeSessionMessage({ role: "assistant", - content: "", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", + content: "Plan:\nAnalyze the code", meta: { asThinking: true }, - ...overrides + }); + const output = renderMessageToStdout(msg, RawMode.Lite); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Thinking")); + assert.ok(output.includes("Plan: Analyze the code")); +}); + +test("renderMessageToStdout renders tool messages with ✧ and tool name", () => { + const payload = JSON.stringify({ name: "read", ok: true }); + const msg = makeSessionMessage({ role: "tool", content: payload }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Read")); +}); + +test("renderMessageToStdout renders tool messages with resultMd output", () => { + const payload = JSON.stringify({ name: "read", ok: true }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { resultMd: "File content:\n line 1\n line 2" }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Read")); + assert.ok(output.includes("└ Result")); + assert.ok(output.includes("File content:")); + assert.ok(output.includes("line 1")); +}); + +test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview and resultMd", () => { + const payload = JSON.stringify({ + name: "UpdatePlan", + ok: true, + metadata: { plan: "Step 1: Analyze\nStep 2: Implement\nStep 3: Test" }, + }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { resultMd: "Plan updated successfully" }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("UpdatePlan")); + assert.ok(output.includes("└ Plan")); + assert.ok(output.includes("Step 1: Analyze")); + assert.ok(output.includes(" Result")); + assert.ok(output.includes("Plan updated successfully")); +}); + +test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview", () => { + const payload = JSON.stringify({ + name: "UpdatePlan", + ok: true, + metadata: { plan: "Step 1: Analyze\nStep 2: Implement\nStep 3: Test" }, + }); + const msg = makeSessionMessage({ role: "tool", content: payload }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("UpdatePlan")); + assert.ok(output.includes("└ Plan")); + assert.ok(output.includes("Step 1: Analyze")); + assert.ok(output.includes("Step 2: Implement")); + // Verify resultMd is NOT included when meta.resultMd is absent + assert.ok(!output.includes("└ Result")); +}); + +test("renderMessageToStdout renders system model change messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "Switched to deepseek-v4-pro", + meta: { isModelChange: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("> Switched to deepseek-v4-pro")); +}); + +test("renderMessageToStdout renders system skill load messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "", + meta: { skill: { name: "code-review", path: "", description: "" } }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("⚡ Loaded skill: code-review")); +}); + +test("renderMessageToStdout renders system summary messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "", + meta: { isSummary: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("(conversation summary inserted)")); +}); + +test("renderMessageToStdout returns empty for unknown system messages", () => { + const msg = makeSessionMessage({ role: "system", content: "" }); + assert.equal(renderMessageToStdout(msg, RawMode.Raw), ""); +}); + +// --- getUpdatePlanPreviewLines tests --- + +test("getUpdatePlanPreviewLines returns empty for failed tool", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: false, metadata: { plan: "Step 1" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for non-UpdatePlan tool", () => { + const summary: ToolSummary = { name: "edit", params: "", ok: true, metadata: { plan: "Step 1" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for missing plan metadata", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: true, metadata: null }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for empty plan string", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: true, metadata: { plan: "" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines extracts plan lines and filters empty rows", () => { + const summary: ToolSummary = { + name: "UpdatePlan", + params: "", + ok: true, + metadata: { plan: "Step 1: Analyze\n\nStep 2: Implement\n \nStep 3: Test" }, }; -} + assert.deepEqual(getUpdatePlanPreviewLines(summary), ["Step 1: Analyze", "Step 2: Implement", "Step 3: Test"]); +}); + +// --- parseToolPayload tests --- + +test("parseToolPayload returns defaults for null content", () => { + const result = parseToolPayload(null); + assert.deepEqual(result, { name: null, ok: true, metadata: null }); +}); + +test("parseToolPayload returns defaults for invalid JSON", () => { + const result = parseToolPayload("not valid json"); + assert.deepEqual(result, { name: null, ok: true, metadata: null }); +}); + +test("parseToolPayload parses valid JSON with name/ok/metadata", () => { + const result = parseToolPayload(JSON.stringify({ name: "read", ok: true, metadata: { file: "src/index.ts" } })); + assert.deepEqual(result, { name: "read", ok: true, metadata: { file: "src/index.ts" } }); +}); + +test("parseToolPayload respects ok: false", () => { + const result = parseToolPayload(JSON.stringify({ name: "bash", ok: false, metadata: null })); + assert.deepEqual(result, { name: "bash", ok: false, metadata: null }); +}); + +test("parseToolPayload trims whitespace from name", () => { + const result = parseToolPayload(JSON.stringify({ name: " read ", ok: true })); + assert.equal(result.name, "read"); +}); diff --git a/src/tests/openai-thinking.test.ts b/src/tests/openai-thinking.test.ts index d767f03..78d8a00 100644 --- a/src/tests/openai-thinking.test.ts +++ b/src/tests/openai-thinking.test.ts @@ -1,41 +1,36 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildThinkingRequestOptions } from "../openai-thinking"; +import { buildThinkingRequestOptions } from "../common/openai-thinking"; -test("buildThinkingRequestOptions returns no thinking payload when disabled", () => { - assert.deepEqual(buildThinkingRequestOptions(false, "https://api.example.com"), {}); +test("buildThinkingRequestOptions explicitly disables thinking", () => { + assert.deepEqual(buildThinkingRequestOptions(false, "https://api.deepseek.com"), { + thinking: { type: "disabled" }, + }); }); -test("buildThinkingRequestOptions keeps top-level thinking for volces endpoints", () => { - assert.deepEqual( - buildThinkingRequestOptions(true, "https://ark.cn-beijing.volces.com/api/v3"), - { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "max" } - } - ); +test("buildThinkingRequestOptions uses the same disabled payload for volces endpoints", () => { + assert.deepEqual(buildThinkingRequestOptions(false, "https://ark.cn-beijing.volces.com/api/v3"), { + thinking: { type: "disabled" }, + }); }); -test("buildThinkingRequestOptions uses extra_body for non-volces endpoints", () => { - assert.deepEqual( - buildThinkingRequestOptions(true, "https://api.deepseek.com"), - { - extra_body: { - thinking: { type: "enabled" }, - reasoning_effort: "max" - } - } - ); +test("buildThinkingRequestOptions enables thinking with default reasoning effort", () => { + assert.deepEqual(buildThinkingRequestOptions(true, "https://api.deepseek.com"), { + thinking: { type: "enabled" }, + extra_body: { reasoning_effort: "max" }, + }); +}); + +test("buildThinkingRequestOptions uses the same enabled payload for volces endpoints", () => { + assert.deepEqual(buildThinkingRequestOptions(true, "https://ark.cn-beijing.volces.com/api/v3"), { + thinking: { type: "enabled" }, + extra_body: { reasoning_effort: "max" }, + }); }); test("buildThinkingRequestOptions accepts high reasoning effort", () => { - assert.deepEqual( - buildThinkingRequestOptions(true, "https://api.deepseek.com", "high"), - { - extra_body: { - thinking: { type: "enabled" }, - reasoning_effort: "high" - } - } - ); + assert.deepEqual(buildThinkingRequestOptions(true, "https://api.deepseek.com", "high"), { + thinking: { type: "enabled" }, + extra_body: { reasoning_effort: "high" }, + }); }); diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts new file mode 100644 index 0000000..aa4f372 --- /dev/null +++ b/src/tests/permission-prompt.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getScopeRiskColor } from "../ui/PermissionPrompt"; + +test("getScopeRiskColor maps permission scopes by risk", () => { + assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); + assert.equal(getScopeRiskColor("query-git-log"), "#22c55e"); + + assert.equal(getScopeRiskColor("read-out-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("write-in-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("network"), "#f59e0b"); + assert.equal(getScopeRiskColor("mcp"), "#f59e0b"); + + assert.equal(getScopeRiskColor("write-out-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("delete-in-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("delete-out-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("mutate-git-log"), "#ef4444"); + assert.equal(getScopeRiskColor("unknown"), "#ef4444"); +}); diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts new file mode 100644 index 0000000..3a28616 --- /dev/null +++ b/src/tests/permissions.test.ts @@ -0,0 +1,273 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + appendProjectPermissionAllows, + computeToolCallPermissions, + evaluatePermissionScopes, + hasUserPermissionReplies, + parseBashSideEffects, +} from "../common/permissions"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("parseBashSideEffects accepts valid scopes and normalizes unsafe values to unknown", () => { + assert.deepEqual(parseBashSideEffects(["read-in-cwd", "network", "read-in-cwd"]), ["read-in-cwd", "network"]); + assert.deepEqual(parseBashSideEffects(undefined), ["unknown"]); + assert.deepEqual(parseBashSideEffects(["read-in-cwd", "unknown"]), ["unknown"]); + assert.deepEqual(parseBashSideEffects(["mcp"]), ["unknown"]); +}); + +test("evaluatePermissionScopes applies deny, ask, allow, and default mode precedence", () => { + const settings = { + allow: ["read-in-cwd" as const], + deny: ["write-out-cwd" as const], + ask: ["network" as const], + defaultMode: "askAll" as const, + }; + + assert.equal(evaluatePermissionScopes(["write-out-cwd"], settings), "deny"); + assert.equal(evaluatePermissionScopes(["network"], settings), "ask"); + assert.equal(evaluatePermissionScopes(["read-in-cwd"], settings), "allow"); + assert.equal(evaluatePermissionScopes(["write-in-cwd"], settings), "ask"); + assert.equal(evaluatePermissionScopes([], settings), "allow"); + assert.equal(evaluatePermissionScopes(["unknown"], settings), "ask"); +}); + +test("computeToolCallPermissions maps tool calls to permission requests", () => { + const projectRoot = createTempDir("deepcode-permissions-workspace-"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: [], + deny: [], + ask: ["write-out-cwd", "network"], + defaultMode: "allowAll", + }, + resolveSnippetPath: () => path.join(projectRoot, "src", "file.ts"), + toolCalls: [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/out.txt", content: "x" }) }, + }, + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "curl https://example.com", sideEffects: ["network"] }), + }, + }, + { + id: "call-edit", + type: "function", + function: { name: "edit", arguments: JSON.stringify({ snippet_id: "snippet_1" }) }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [ + { toolCallId: "call-write", permission: "ask" }, + { toolCallId: "call-bash", permission: "ask" }, + { toolCallId: "call-edit", permission: "allow" }, + ]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [ + { id: "call-write", scopes: ["write-out-cwd"] }, + { id: "call-bash", scopes: ["network"] }, + ] + ); +}); + +test("computeToolCallPermissions only asks for scopes not already allowed", () => { + const projectRoot = createTempDir("deepcode-permissions-filter-workspace-"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: ["read-in-cwd"], + deny: [], + ask: [], + defaultMode: "askAll", + }, + toolCalls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "curl -s http://localhost:8899/ && ls index.html", + sideEffects: ["network", "read-in-cwd"], + }), + }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [{ toolCallId: "call-bash", permission: "ask" }]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [{ id: "call-bash", scopes: ["network"] }] + ); +}); + +test("appendProjectPermissionAllows writes unique project-level allow scopes", () => { + const projectRoot = createTempDir("deepcode-permission-settings-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ permissions: { allow: ["read-in-cwd"] } }), "utf8"); + + appendProjectPermissionAllows(projectRoot, ["read-in-cwd", "write-in-cwd"]); + appendProjectPermissionAllows(projectRoot, ["write-in-cwd"]); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd", "write-in-cwd"]); +}); + +test("appendProjectPermissionAllows seeds inherited permissions before adding allow scopes", () => { + const projectRoot = createTempDir("deepcode-permission-settings-default-"); + + appendProjectPermissionAllows(projectRoot, ["query-git-log"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "query-git-log"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows moves inherited ask and deny scopes into allow", () => { + const projectRoot = createTempDir("deepcode-permission-settings-move-inherited-"); + + appendProjectPermissionAllows(projectRoot, ["network", "write-out-cwd"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network", "mcp"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "network", "write-out-cwd"], + deny: [], + ask: ["mcp"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows writes inherited permissions even when scope is already allowed", () => { + const projectRoot = createTempDir("deepcode-permission-settings-inherited-existing-"); + + appendProjectPermissionAllows(projectRoot, ["read-in-cwd"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: [], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd"], + deny: [], + ask: ["network"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows preserves existing project permissions", () => { + const projectRoot = createTempDir("deepcode-permission-settings-explicit-default-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ permissions: { allow: ["read-in-cwd"], defaultMode: "allowAll" } }), + "utf8" + ); + + appendProjectPermissionAllows(projectRoot, ["query-git-log"], { + inheritedPermissions: { + allow: ["write-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "query-git-log"], + defaultMode: "allowAll", + }); +}); + +test("appendProjectPermissionAllows removes existing ask and deny conflicts", () => { + const projectRoot = createTempDir("deepcode-permission-settings-existing-conflict-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + permissions: { + allow: ["read-in-cwd"], + deny: ["network", "write-out-cwd"], + ask: ["network", "mcp"], + defaultMode: "askAll", + }, + }), + "utf8" + ); + + appendProjectPermissionAllows(projectRoot, ["network"]); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "network"], + deny: ["write-out-cwd"], + ask: ["mcp"], + defaultMode: "askAll", + }); +}); + +test("hasUserPermissionReplies detects permission reply payloads", () => { + assert.equal(hasUserPermissionReplies({}), false); + assert.equal(hasUserPermissionReplies({ permissions: [] }), false); + assert.equal(hasUserPermissionReplies({ permissions: [{ toolCallId: "call-1", permission: "allow" }] }), true); + assert.equal(hasUserPermissionReplies({ alwaysAllows: ["network"] }), true); +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} diff --git a/src/tests/process-tree.test.ts b/src/tests/process-tree.test.ts new file mode 100644 index 0000000..1dd08a1 --- /dev/null +++ b/src/tests/process-tree.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { killProcessTree, runWindowsTaskkill } from "../common/process-tree"; + +test("runWindowsTaskkill invokes taskkill for the full process tree", () => { + const calls: Array<{ command: string; args: string[]; options: { stdio: "ignore"; windowsHide: true } }> = []; + + const ok = runWindowsTaskkill(1234, (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0 }; + }); + + assert.equal(ok, true); + assert.deepEqual(calls, [ + { + command: "taskkill", + args: ["/PID", "1234", "/T", "/F"], + options: { stdio: "ignore", windowsHide: true }, + }, + ]); +}); + +test("runWindowsTaskkill reports failure for non-zero exits and spawn errors", () => { + assert.equal( + runWindowsTaskkill(1234, () => ({ + status: 1, + })), + false + ); + assert.equal( + runWindowsTaskkill(1234, () => ({ + status: null, + error: new Error("taskkill missing"), + })), + false + ); +}); + +test("killProcessTree uses taskkill on Windows", () => { + const killed: number[] = []; + + const ok = killProcessTree(1234, "SIGKILL", { + platform: "win32", + runTaskkill: (pid) => { + killed.push(pid); + return true; + }, + killPid: () => { + throw new Error("direct kill should not be used"); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(killed, [1234]); +}); + +test("killProcessTree falls back to direct kill on Windows taskkill failure", () => { + const directKills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGTERM", { + platform: "win32", + runTaskkill: () => false, + killPid: (pid, signal) => { + directKills.push({ pid, signal }); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(directKills, [{ pid: 1234, signal: "SIGTERM" }]); +}); + +test("killProcessTree returns false on Windows when all kill attempts fail", () => { + const ok = killProcessTree(1234, "SIGKILL", { + platform: "win32", + runTaskkill: () => false, + killPid: () => { + throw new Error("missing process"); + }, + }); + + assert.equal(ok, false); +}); + +test("killProcessTree kills a process group before direct PID on non-Windows platforms", () => { + const kills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGKILL", { + platform: "darwin", + killPid: (pid, signal) => { + kills.push({ pid, signal }); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(kills, [{ pid: -1234, signal: "SIGKILL" }]); +}); + +test("killProcessTree falls back to direct PID on non-Windows group failure", () => { + const kills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGTERM", { + platform: "linux", + killPid: (pid, signal) => { + kills.push({ pid, signal }); + if (pid < 0) { + throw new Error("no process group"); + } + }, + }); + + assert.equal(ok, true); + assert.deepEqual(kills, [ + { pid: -1234, signal: "SIGTERM" }, + { pid: 1234, signal: "SIGTERM" }, + ]); +}); + +test("killProcessTree can skip non-Windows process group killing", () => { + const kills: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + const ok = killProcessTree(1234, "SIGTERM", { + platform: "linux", + killGroupOnNonWindows: false, + killPid: (pid, signal) => { + kills.push({ pid, signal }); + }, + }); + + assert.equal(ok, true); + assert.deepEqual(kills, [{ pid: 1234, signal: "SIGTERM" }]); +}); + +test("killProcessTree ignores invalid PIDs", () => { + for (const pid of [0, -1, 1.5, Number.NaN]) { + assert.equal( + killProcessTree(pid, "SIGKILL", { + platform: "win32", + runTaskkill: () => { + throw new Error("taskkill should not be used"); + }, + killPid: () => { + throw new Error("direct kill should not be used"); + }, + }), + false + ); + } +}); diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index 664fdec..953de7c 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -1,13 +1,96 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { getSystemPrompt, getTools } from "../prompt"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { getDefaultSkillPrompt, getRuntimeContext, getSystemPrompt, getTools } from "../prompt"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); test("getTools always includes WebSearch", () => { const names = getTools().map((tool) => tool.function.name); assert.equal(names.includes("WebSearch"), true); }); +test("getTools includes UpdatePlan with string plan schema", () => { + const tool = getTools().find((candidate) => candidate.function.name === "UpdatePlan"); + assert.ok(tool); + assert.deepEqual(tool.function.parameters.required, ["plan"]); + assert.equal((tool.function.parameters.properties.plan as { type?: unknown }).type, "string"); +}); + +test("getTools requires bash sideEffects permission scopes", () => { + const tool = getTools().find((candidate) => candidate.function.name === "bash"); + assert.ok(tool); + assert.deepEqual(tool.function.parameters.required, ["command", "sideEffects"]); + const sideEffects = tool.function.parameters.properties.sideEffects as { + type?: unknown; + items?: { enum?: unknown[] }; + }; + assert.equal(sideEffects.type, "array"); + assert.equal(sideEffects.items?.enum?.includes("write-out-cwd"), true); + assert.equal(sideEffects.items?.enum?.includes("unknown"), true); +}); + test("getSystemPrompt always includes WebSearch docs", () => { const prompt = getSystemPrompt("/tmp/project"); assert.equal(prompt.includes("## WebSearch"), true); }); + +test("getSystemPrompt includes UpdatePlan docs", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("## UpdatePlan"), true); + assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true); +}); + +test("getSystemPrompt does not include runtime context", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("# Local Workspace Environment"), false); + assert.equal(prompt.includes('"root path": "/tmp/project"'), false); +}); + +test("getDefaultSkillPrompt loads default skill templates in order", () => { + const prompt = getDefaultSkillPrompt(); + const agentDriftIndex = prompt.indexOf(""); + const planIndex = prompt.indexOf(""); + + assert.notEqual(agentDriftIndex, -1); + assert.notEqual(planIndex, -1); + assert.equal(agentDriftIndex < planIndex, true); + assert.equal(prompt.includes("Use the skill documents below to assist the user:"), true); + assert.equal(prompt.includes('path="templates/skills/'), false); +}); + +test("getSystemPrompt does not include current date guidance", () => { + const now = new Date(); + const expected = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes(expected), false); +}); + +test("getRuntimeContext includes current date and model guidance", () => { + const now = new Date(); + const expectedDate = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; + const prompt = getRuntimeContext("/tmp/project", "deepseek-v4-pro"); + assert.equal(prompt.includes(expectedDate), true); + assert.equal(prompt.includes("当前LLM模型为deepseek-v4-pro,对话中可通过/model命令切换模型。"), true); + assert.equal(prompt.includes("# Local Workspace Environment"), true); + assert.equal(prompt.includes('"root path": "/tmp/project"'), true); +}); + +test("getSystemPrompt renders Read docs for non-multimodal models", () => { + const prompt = getSystemPrompt("/tmp/project", { model: "deepseek-chat" }); + assert.equal(prompt.includes("the current model is not multimodal"), true); + assert.equal(prompt.includes("the contents are presented visually"), false); +}); + +test("runtime prompt assets live under templates", () => { + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "web-search.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "prompts", "init_command.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "agent-drift-guard.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "plan-and-execute.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md")), false); + assert.equal(fs.existsSync(path.join(repoRoot, "docs", "tools")), false); + assert.equal(fs.existsSync(path.join(repoRoot, "docs", "prompts")), false); +}); diff --git a/src/tests/promptBuffer.test.ts b/src/tests/promptBuffer.test.ts index d35fa65..67ac23a 100644 --- a/src/tests/promptBuffer.test.ts +++ b/src/tests/promptBuffer.test.ts @@ -5,6 +5,7 @@ import { backspace, deleteForward, deleteWordBefore, + deleteWordAfter, getCurrentSlashToken, insertText, killLine, @@ -15,8 +16,8 @@ import { moveRight, moveWordLeft, moveWordRight, - moveUp -} from "../ui/promptBuffer"; + moveUp, +} from "../ui"; test("insertText appends text and advances the cursor", () => { const next = insertText(EMPTY_BUFFER, "hello"); @@ -94,6 +95,12 @@ test("deleteWordBefore removes the previous word and any adjacent whitespace", ( assert.equal(result.cursor, 4); }); +test("deleteWordAfter removes the next word and leading whitespace", () => { + const result = deleteWordAfter({ text: "ask the model now", cursor: 3 }); + assert.equal(result.text, "ask model now"); + assert.equal(result.cursor, 3); +}); + test("getCurrentSlashToken returns the slash word at the cursor", () => { const buffer = { text: "/skill", cursor: 6 }; assert.equal(getCurrentSlashToken(buffer), "/skill"); diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 3fa9e9a..4f8b4d9 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -1,18 +1,41 @@ import { test } from "node:test"; import assert from "node:assert/strict"; + +const ANSI_RE = /\u001b\[[0-9;]*m/g; +function stripAnsi(text: string): string { + return text.replace(ANSI_RE, ""); +} + import { IMAGE_ATTACHMENT_CLEAR_HINT, addUniqueSkill, formatImageAttachmentStatus, formatSelectedSkillsStatus, getPromptCursorPlacement, + getPromptReturnKeyAction, isClearImageAttachmentsShortcut, parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, - renderBufferWithCursor -} from "../ui/PromptInput"; -import type { SkillInfo } from "../session"; + renderBufferWithCursor, + buildInitPromptSubmission, + buildPromptDraftFromSessionMessage, + dispatchTerminalInput, + disableTerminalExtendedKeys, + enableTerminalExtendedKeys, + EMPTY_BUFFER, + insertText, + backspace, +} from "../ui"; +import type { SessionMessage, SkillInfo } from "../session"; + +function collectDispatchedInput(data: string) { + const events: ReturnType[] = []; + dispatchTerminalInput(data, (input, key) => { + events.push({ input, key }); + }); + return events; +} test("parseTerminalInput treats DEL bytes as backspace", () => { const { input, key } = parseTerminalInput("\u007F"); @@ -54,6 +77,59 @@ test("parseTerminalInput recognizes word navigation modifiers", () => { assert.equal(metaRight.key.meta, true); }); +test("parseTerminalInput keeps DEL payload for meta+backspace", () => { + const { input, key } = parseTerminalInput("\u001B\u007F"); + assert.equal(input, "\u007F"); + assert.equal(key.meta, true); + assert.equal(key.backspace, false); +}); + +test("dispatchTerminalInput splits iOS CJK composition packets", () => { + const events = collectDispatchedInput("가\u007F나"); + assert.equal(events.length, 3); + assert.equal(events[0]?.input, "가"); + assert.equal(events[1]?.input, ""); + assert.equal(events[1]?.key.backspace, true); + assert.equal(events[2]?.input, "나"); +}); + +test("dispatchTerminalInput applies multi-step CJK composition to the prompt buffer", () => { + let state = EMPTY_BUFFER; + dispatchTerminalInput("ㄱ\u007F가\u007F각", (input, key) => { + if (key.backspace) { + state = backspace(state); + return; + } + state = insertText(state, input); + }); + + assert.equal(state.text, "각"); + assert.equal(state.cursor, 1); +}); + +test("dispatchTerminalInput preserves meta+backspace as one event", () => { + const events = collectDispatchedInput("\u001B\u007F"); + assert.equal(events.length, 1); + assert.equal(events[0]?.input, "\u007F"); + assert.equal(events[0]?.key.meta, true); + assert.equal(events[0]?.key.backspace, false); + assert.equal(events[0]?.key.escape, false); +}); + +test("dispatchTerminalInput emits consecutive backspaces from one packet", () => { + const events = collectDispatchedInput("\u007F\u007F"); + assert.equal(events.length, 2); + assert.equal(events[0]?.key.backspace, true); + assert.equal(events[1]?.key.backspace, true); +}); + +test("parseTerminalInput keeps BS payload for meta+backspace", () => { + const { input, key } = parseTerminalInput("\u001B\b"); + assert.equal(input, "\b"); + assert.equal(key.meta, true); + assert.equal(key.backspace, false); +}); + test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); assert.equal(input, "\r"); @@ -62,6 +138,57 @@ test("parseTerminalInput recognizes shifted return sequences", () => { assert.equal(key.meta, false); }); +test("prompt return key action submits on plain enter", () => { + const { key } = parseTerminalInput("\r"); + assert.equal(getPromptReturnKeyAction(key), "submit"); +}); + +test("prompt return key action inserts newline on shift+enter", () => { + const { key } = parseTerminalInput("\u001B[13;2u"); + assert.equal(key.return, true); + assert.equal(key.shift, true); + assert.equal(getPromptReturnKeyAction(key), "newline"); +}); + +test("parseTerminalInput recognizes alternate shifted return sequences", () => { + for (const sequence of ["\u001B[13;2~", "\u001B[27;2;13~"]) { + const { key } = parseTerminalInput(sequence); + assert.equal(key.return, true); + assert.equal(key.shift, true); + assert.equal(getPromptReturnKeyAction(key), "newline"); + } +}); + +test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { + assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); +}); + +test("buildPromptDraftFromSessionMessage restores text and image urls", () => { + const message: SessionMessage = { + id: "user-with-images", + sessionId: "session-1", + role: "user", + content: "revise this prompt", + contentParams: [ + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + { type: "text", text: "ignored" }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,def" } }, + ], + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }; + + assert.deepEqual(buildPromptDraftFromSessionMessage(message, 7), { + nonce: 7, + text: "revise this prompt", + imageUrls: ["data:image/png;base64,abc", "data:image/jpeg;base64,def"], + }); +}); + test("parseTerminalInput recognizes terminal focus events", () => { const focusIn = parseTerminalInput("\u001B[I"); const focusOut = parseTerminalInput("\u001B[O"); @@ -78,6 +205,44 @@ test("parseTerminalInput recognizes ctrl+x as the image attachment clear shortcu assert.equal(isClearImageAttachmentsShortcut(input, key), true); }); +test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (standard)", () => { + const { input, key } = parseTerminalInput("\u001B[45;5u"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (extended)", () => { + const { input, key } = parseTerminalInput("\u001B[27;5;45~"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes raw 0x1F as ctrl+shift+- (redo)", () => { + const { input, key } = parseTerminalInput("\u001F"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (standard)", () => { + const { input, key } = parseTerminalInput("\u001B[45;6u"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + +test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (extended)", () => { + const { input, key } = parseTerminalInput("\u001B[27;6;45~"); + assert.equal(input, "-"); + assert.equal(key.ctrl, true); + assert.equal(key.shift, true); + assert.equal(key.meta, false); +}); + test("formatImageAttachmentStatus formats the image count label", () => { assert.equal(formatImageAttachmentStatus(0), ""); assert.equal(formatImageAttachmentStatus(1), "📎 1 image attached"); @@ -85,6 +250,17 @@ test("formatImageAttachmentStatus formats the image count label", () => { assert.equal(IMAGE_ATTACHMENT_CLEAR_HINT, "ctrl+x clear images"); }); +test("buildInitPromptSubmission preserves manually selected skills", () => { + const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; + + assert.deepEqual(buildInitPromptSubmission([skill]), { + text: "/init", + imageUrls: [], + selectedSkills: [skill], + }); + assert.deepEqual(buildInitPromptSubmission([]), { text: "/init", imageUrls: [], selectedSkills: undefined }); +}); + test("selected skill helpers format, dedupe, toggle, and clear slash tokens", () => { const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; const other: SkillInfo = { name: "code-review", path: "/skills/code-review/SKILL.md", description: "Review code" }; @@ -104,31 +280,39 @@ test("renderBufferWithCursor hides the simulated cursor when unfocused", () => { }); test("renderBufferWithCursor draws the simulated cursor when focused", () => { - assert.equal(renderBufferWithCursor({ text: "", cursor: 0 }, true), " "); - assert.equal(renderBufferWithCursor({ text: "hello", cursor: 5 }, true), "hello "); - assert.equal(renderBufferWithCursor({ text: "hello", cursor: 1 }, true), "hello"); - assert.equal(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true), "hello\n "); - assert.equal(renderBufferWithCursor({ text: "\n", cursor: 1 }, true), "\n "); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true)), " "); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything")), " Ask anything"); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello", cursor: 5 }, true)), "hello "); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello", cursor: 1 }, true)), "hello"); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true)), "hello\n "); + assert.equal(stripAnsi(renderBufferWithCursor({ text: "\n", cursor: 1 }, true)), "\n "); +}); + +test("renderBufferWithCursor styles exactly one simulated cursor", () => { + assert.equal((renderBufferWithCursor({ text: "", cursor: 0 }, true).match(ANSI_RE) ?? []).length, 2); + assert.ok(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything").includes("\u001B[7m \u001B[27m")); + assert.equal((renderBufferWithCursor({ text: "hello", cursor: 1 }, true).match(ANSI_RE) ?? []).length, 2); + assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2); }); test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { - const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, "❯ ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); }); test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => { - const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, "❯ ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 2 }); }); test("getPromptCursorPlacement accounts for CJK character width", () => { - const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, "❯ ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send"); assert.equal(placement.column, 6); }); test("getPromptCursorPlacement accounts for multiline buffer rows", () => { - const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, "❯ ", "Enter send"); + const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send"); assert.deepEqual(placement, { rowsUp: 3, column: 7 }); - const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, "❯ ", "Enter send"); + const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); assert.deepEqual(middle, { rowsUp: 4, column: 4 }); }); diff --git a/src/tests/promptUndoRedo.test.ts b/src/tests/promptUndoRedo.test.ts new file mode 100644 index 0000000..c1999f1 --- /dev/null +++ b/src/tests/promptUndoRedo.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { removeCurrentSlashToken } from "../ui"; +import { + clearPromptUndoRedoState, + createPromptUndoRedoState, + recordPromptEdit, + redoPromptEdit, + undoPromptEdit, +} from "../ui/promptUndoRedo"; + +test("prompt undo and redo restore edited buffer states", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const hello = { text: "hello", cursor: 5 }; + + recordPromptEdit(history, empty, hello); + + assert.deepEqual(undoPromptEdit(history, hello), empty); + assert.deepEqual(redoPromptEdit(history, empty), hello); +}); + +test("prompt redo history is cleared after a new edit", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const first = { text: "first", cursor: 5 }; + const second = { text: "second", cursor: 6 }; + + recordPromptEdit(history, empty, first); + assert.deepEqual(undoPromptEdit(history, first), empty); + + recordPromptEdit(history, empty, second); + + assert.equal(redoPromptEdit(history, second), null); +}); + +test("prompt undo ignores cursor-only movement", () => { + const history = createPromptUndoRedoState(); + const before = { text: "hello", cursor: 5 }; + const after = { text: "hello", cursor: 0 }; + + recordPromptEdit(history, before, after); + + assert.equal(undoPromptEdit(history, after), null); +}); + +test("clearing consumed slash token drops undo and redo history", () => { + const history = createPromptUndoRedoState(); + const empty = { text: "", cursor: 0 }; + const slashCommand = { text: "/model", cursor: 6 }; + + recordPromptEdit(history, empty, slashCommand); + const cleared = removeCurrentSlashToken(slashCommand); + clearPromptUndoRedoState(history); + + assert.deepEqual(cleared, { text: "", cursor: 0 }); + assert.equal(undoPromptEdit(history, cleared), null); + assert.equal(redoPromptEdit(history, cleared), null); +}); diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs new file mode 100644 index 0000000..4d09f5b --- /dev/null +++ b/src/tests/run-tests.mjs @@ -0,0 +1,13 @@ +// Cross-platform test runner: finds all *.test.ts files and runs them via tsx. +// Uses the glob package for reliable cross-platform pattern expansion (Node 20+). +/* eslint-disable */ + +import { globSync } from "glob"; +import { spawnSync } from "child_process"; + +const cwd = new URL("../..", import.meta.url); +const testFiles = globSync("src/tests/*.test.ts", { cwd }); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { stdio: "inherit", cwd }); + +process.exit(result.status ?? 1); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index cc4c3a3..95de8e3 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1,21 +1,39 @@ import { afterEach, test } from "node:test"; import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { GitFileHistory } from "../common/file-history"; import { SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; +const originalConsoleWarn = console.warn; const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; const tempDirs: string[] = []; +/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + afterEach(() => { globalThis.fetch = originalFetch; + console.warn = originalConsoleWarn; if (originalHome === undefined) { delete process.env.HOME; } else { process.env.HOME = originalHome; } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -31,11 +49,11 @@ test("SessionManager preserves structured system content when building OpenAI me createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const messages: SessionMessage[] = [ @@ -47,18 +65,18 @@ test("SessionManager preserves structured system content when building OpenAI me contentParams: [ { type: "image_url", - image_url: { url: "data:image/png;base64,abc123" } - } + image_url: { url: "data:image/png;base64,abc123" }, + }, ], messageParams: null, compacted: false, visible: false, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } + updateTime: "2026-01-01T00:00:00.000Z", + }, ]; - const openAIMessages = (manager as any).buildOpenAIMessages(messages) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ role: string; content: unknown; }>; @@ -69,22 +87,64 @@ test("SessionManager preserves structured system content when building OpenAI me { type: "text", text: "The read tool has loaded `pixel.png`." }, { type: "image_url", - image_url: { url: "data:image/png;base64,abc123" } - } + image_url: { url: "data:image/png;base64,abc123" }, + }, ]); }); +test("SessionManager filters image content for non-multimodal models", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "deepseek-chat", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "deepseek-chat" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const messages: SessionMessage[] = [ + { + id: "system-image", + sessionId: "session-1", + role: "system", + content: "The read tool has loaded `pixel.png`.", + contentParams: [ + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ], + messageParams: null, + compacted: false, + visible: false, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ]; + + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "deepseek-chat") as Array<{ + role: string; + content: unknown; + }>; + + assert.equal(openAIMessages.length, 1); + assert.deepEqual(openAIMessages[0]?.content, [{ type: "text", text: "The read tool has loaded `pixel.png`." }]); +}); + test("SessionManager preserves empty reasoning content on assistant tool calls", () => { const manager = new SessionManager({ projectRoot: process.cwd(), createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const message = (manager as any).buildAssistantMessage( @@ -94,8 +154,8 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" } - } + function: { name: "read", arguments: "{}" }, + }, ], "" ) as SessionMessage; @@ -105,13 +165,13 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" } - } + function: { name: "read", arguments: "{}" }, + }, ], - reasoning_content: "" + reasoning_content: "", }); - const openAIMessages = (manager as any).buildOpenAIMessages([message], true) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages([message], true, "test-model") as Array<{ reasoning_content?: string; }>; @@ -124,11 +184,11 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const messages: SessionMessage[] = [ @@ -143,29 +203,26 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" } - } - ] + function: { name: "read", arguments: "{}" }, + }, + ], }, compacted: false, visible: false, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } + updateTime: "2026-01-01T00:00:00.000Z", + }, ]; - const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ + const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ reasoning_content?: string; }>; - const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ reasoning_content?: string; }>; assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal( - Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), - false - ); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); }); test("SessionManager replays normal assistant messages with reasoning content in thinking mode", () => { @@ -174,11 +231,11 @@ test("SessionManager replays normal assistant messages with reasoning content in createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); const messages: SessionMessage[] = [ @@ -192,28 +249,25 @@ test("SessionManager replays normal assistant messages with reasoning content in compacted: false, visible: true, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } + updateTime: "2026-01-01T00:00:00.000Z", + }, ]; - const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ + const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ reasoning_content?: string; }>; - const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false) as Array<{ + const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ reasoning_content?: string; }>; assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal( - Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), - false - ); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); }); test("SessionManager normalizes legacy sessions without activeTokens to zero", () => { const workspace = createTempDir("deepcode-legacy-active-tokens-workspace-"); const home = createTempDir("deepcode-legacy-active-tokens-home-"); - process.env.HOME = home; + setHomeDir(home); const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); const projectDir = path.join(home, ".deepcode", "projects", projectCode); @@ -229,9 +283,9 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( status: "completed", usage: { total_tokens: 123 }, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z" - } - ] + updateTime: "2026-01-01T00:00:00.000Z", + }, + ], }), "utf8" ); @@ -239,12 +293,26 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( const manager = createSessionManager(workspace, "machine-id-legacy"); assert.equal(manager.getSession("legacy-session")?.activeTokens, 0); + assert.equal(manager.getSession("legacy-session")?.usagePerModel, null); +}); + +test("SessionManager keeps usagePerModel null until response usage is available", async () => { + const workspace = createTempDir("deepcode-null-usage-per-model-workspace-"); + const home = createTempDir("deepcode-null-usage-per-model-home-"); + setHomeDir(home); + + const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]); + + const sessionId = await manager.createSession({ text: "" }); + + assert.equal(manager.getSession(sessionId)?.usage, null); + assert.equal(manager.getSession(sessionId)?.usagePerModel, null); }); test("SessionManager marks skills loaded from existing session messages", async () => { const workspace = createTempDir("deepcode-loaded-skills-workspace-"); const home = createTempDir("deepcode-loaded-skills-home-"); - process.env.HOME = home; + setHomeDir(home); const skillDir = path.join(home, ".agents", "skills", "lessweb-starter"); fs.mkdirSync(skillDir, { recursive: true }); @@ -275,31 +343,426 @@ test("SessionManager marks skills loaded from existing session messages", async name: "lessweb-starter", path: "~/.agents/skills/lessweb-starter/SKILL.md", description: "Create Lessweb projects", - isLoaded: true - } - } + isLoaded: true, + }, + }, })}\n`, "utf8" ); const manager = createSessionManager(workspace, "machine-id-loaded-skills"); - const loadedSkill = (await manager.listSkills("loaded-session")) - .find((skill) => skill.name === "lessweb-starter"); + const loadedSkill = (await manager.listSkills("loaded-session")).find((skill) => skill.name === "lessweb-starter"); assert.equal(loadedSkill?.isLoaded, true); }); +test("SessionManager lists project skills from .agents with legacy .deepcode compatibility", async () => { + const workspace = createTempDir("deepcode-project-skills-workspace-"); + const home = createTempDir("deepcode-project-skills-home-"); + setHomeDir(home); + + const userSkillDir = path.join(home, ".agents", "skills", "shared"); + fs.mkdirSync(userSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(userSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: User-level skill\n---\n# Shared\n", + "utf8" + ); + + const legacyProjectSkillDir = path.join(workspace, ".deepcode", "skills", "legacy"); + fs.mkdirSync(legacyProjectSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(legacyProjectSkillDir, "SKILL.md"), + "---\nname: legacy\ndescription: Legacy project skill\n---\n# Legacy\n", + "utf8" + ); + + const projectAgentsSkillDir = path.join(workspace, ".agents", "skills", "shared"); + fs.mkdirSync(projectAgentsSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(projectAgentsSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: Project .agents skill\n---\n# Shared\n", + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-project-skills"); + const skills = await manager.listSkills(); + const legacySkill = skills.find((skill) => skill.name === "legacy"); + const sharedSkill = skills.find((skill) => skill.name === "shared"); + + assert.equal(legacySkill?.path, "./.deepcode/skills/legacy/SKILL.md"); + assert.equal(legacySkill?.description, "Legacy project skill"); + assert.equal(sharedSkill?.path, "./.agents/skills/shared/SKILL.md"); + assert.equal(sharedSkill?.description, "Project .agents skill"); +}); + +test("SessionManager dispose disconnects MCP servers", async () => { + const workspace = createTempDir("deepcode-mcp-dispose-workspace-"); + const serverPath = path.join(workspace, "mcp-server.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + if (request.params && request.params.cursor === "page-2") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "count", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } } + ], nextCursor: "page-2" } }); + return; + } + if (request.method === "tools/call") { + send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: request.params.name + ":" + (request.params.arguments.text || "") }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-dispose"); + const initPromise = manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "smoke", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, + ]); + + await initPromise; + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "smoke", + status: "ready", + connected: true, + toolCount: 2, + tools: ["mcp__smoke__echo", "mcp__smoke__count"], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, + ]); + const mcpManager = (manager as any).mcpManager; + assert.equal(mcpManager.getMcpToolDefinitions()[0].function.name, "mcp__smoke__echo"); + assert.deepEqual(await mcpManager.executeMcpTool("mcp__smoke__echo", { text: "ok" }), { + ok: true, + name: "mcp__smoke__echo", + output: "echo:ok", + }); + + manager.dispose(); + + assert.deepEqual(manager.getMcpStatus(), []); +}); + +test("SessionManager refreshes cached MCP tool definitions after server crash", async () => { + const workspace = createTempDir("deepcode-mcp-crash-cache-workspace-"); + const serverPath = path.join(workspace, "mcp-server-crash.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "echo", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + if (request.method === "prompts/list") { + send({ jsonrpc: "2.0", id: request.id, result: { prompts: [] } }); + return; + } + if (request.method === "resources/list") { + send({ jsonrpc: "2.0", id: request.id, result: { resources: [] } }); + setTimeout(() => process.exit(9), 10); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-crash-cache"); + await manager.initMcpServers({ crashy: { command: process.execPath, args: [serverPath] } }); + + assert.equal(manager.getMcpStatus()[0]?.status, "ready"); + assert.equal((manager as any).mcpToolDefinitions.length, 1); + + await waitForMcpStatus(manager, "failed"); + + assert.equal((manager as any).mcpToolDefinitions.length, 0); + + manager.dispose(); +}); + +test("SessionManager reports configured MCP servers as starting before initialization", () => { + const workspace = createTempDir("deepcode-mcp-configured-workspace-"); + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ + model: "test-model", + mcpServers: { + playwright: { command: "npx", args: ["@playwright/mcp@latest"] }, + }, + }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "playwright", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, + ]); +}); + +test("SessionManager reports MCP startup stderr on failure", async () => { + const workspace = createTempDir("deepcode-mcp-failure-workspace-"); + const serverPath = path.join(workspace, "mcp-server-fail.cjs"); + fs.writeFileSync(serverPath, 'process.stderr.write("mcp startup boom"); process.exit(7);', "utf8"); + + const manager = createSessionManager(workspace, "machine-id-mcp-failure"); + await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); + + const [status] = manager.getMcpStatus(); + assert.equal(status?.name, "broken"); + assert.equal(status?.status, "failed"); + assert.equal(status?.connected, false); + assert.match(status?.error ?? "", /mcp startup boom/); +}); + +test( + "SessionManager adds -y when launching MCP servers through npx", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-mcp-npx-workspace-"); + const argsPath = path.join(workspace, "args.json"); + const fakeNpxPath = path.join(workspace, "npx"); + fs.writeFileSync( + fakeNpxPath, + `#!/usr/bin/env node +const fs = require("fs"); +const readline = require("readline"); +fs.writeFileSync(process.env.ARGS_PATH, JSON.stringify(process.argv.slice(2))); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + fs.chmodSync(fakeNpxPath, 0o755); + + const manager = createSessionManager(workspace, "machine-id-mcp-npx"); + await manager.initMcpServers({ + npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, + }); + + assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); + manager.dispose(); + } +); + +test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { + const workspace = createTempDir("deepcode-init-deepcode-workspace-"); + const home = createTempDir("deepcode-init-deepcode-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".deepcode", "AGENTS.md"), "deepcode project instructions", "utf8"); + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-deepcode"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + }>; + const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); + const systemContents = messages + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(userMessage?.content, "/init"); + assert.match(openAIUserMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); + assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.ok(systemContents.includes("deepcode project instructions")); + assert.ok(!systemContents.includes("root project instructions")); +}); + +test("createSession appends default system prompts in prefix-cache-friendly order", async () => { + const workspace = createTempDir("deepcode-system-order-workspace-"); + const home = createTempDir("deepcode-system-order-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-system-order"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "hello" }); + const systemContents = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(systemContents.length >= 4, true); + assert.match(systemContents[0] ?? "", /# Available Tools/); + assert.doesNotMatch(systemContents[0] ?? "", /# Local Workspace Environment/); + assert.doesNotMatch(systemContents[0] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[1] ?? "", //); + assert.match(systemContents[1] ?? "", //); + assert.doesNotMatch(systemContents[1] ?? "", /path="templates\/skills\//); + assert.doesNotMatch(systemContents[1] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[2] ?? "", /# Local Workspace Environment/); + assert.match(systemContents[2] ?? "", /当前LLM模型为test-model/); + const environmentJsonMatch = (systemContents[2] ?? "").match(/```json\n([\s\S]+?)\n```/); + assert.ok(environmentJsonMatch); + const environmentInfo = JSON.parse(environmentJsonMatch[1] ?? "{}") as { "root path"?: string }; + assert.equal(environmentInfo["root path"], workspace); + assert.equal(systemContents[3], "root project instructions"); +}); + +test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { + const workspace = createTempDir("deepcode-init-root-workspace-"); + const home = createTempDir("deepcode-init-root-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-root"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await manager.replySession(sessionId, { text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessages = messages.filter((message) => message.role === "user"); + const replyMessage = userMessages[userMessages.length - 1]; + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + }>; + const openAIUserMessages = openAIMessages.filter((message) => message.role === "user"); + const openAIReplyMessage = openAIUserMessages[openAIUserMessages.length - 1]; + + assert.equal(replyMessage?.content, "/init"); + assert.match(openAIReplyMessage?.content ?? "", /Update \.\/AGENTS\.md/); +}); + +test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { + const workspace = createTempDir("deepcode-init-generate-workspace-"); + const home = createTempDir("deepcode-init-generate-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(home, ".deepcode", "AGENTS.md"), "user instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-generate"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + }>; + const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); + + assert.equal(userMessage?.content, "/init"); + assert.match(openAIUserMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); + assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); +}); + test("createSession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-session-workspace-"); const home = createTempDir("deepcode-session-home-"); - process.env.HOME = home; + setHomeDir(home); const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { fetchCalls.push({ input, init }); return { ok: true, - text: async () => "" + text: async () => "", } as Response; }) as typeof fetch; @@ -317,6 +780,7 @@ test("createSession reports a new prompt with the machineId token", async () => assert.equal(fetchCalls.length, 1); assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); assert.equal(fetchCalls[0].init?.method, "POST"); + assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-123"); }); @@ -324,14 +788,14 @@ test("createSession reports a new prompt with the machineId token", async () => test("replySession reports a new prompt with the machineId token", async () => { const workspace = createTempDir("deepcode-reply-workspace-"); const home = createTempDir("deepcode-reply-home-"); - process.env.HOME = home; + setHomeDir(home); const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { fetchCalls.push({ input, init }); return { ok: true, - text: async () => "" + text: async () => "", } as Response; }) as typeof fetch; @@ -348,82 +812,1214 @@ test("replySession reports a new prompt with the machineId token", async () => { assert.equal(fetchCalls.length, 1); assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); assert.equal(fetchCalls[0].init?.method, "POST"); + assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); }); -test("replySession closes pending tool calls before appending a new user message", async () => { - const workspace = createTempDir("deepcode-pending-tool-workspace-"); - const home = createTempDir("deepcode-pending-tool-home-"); - process.env.HOME = home; +test("reporting a new prompt does not warn when the background request fails", async () => { + const workspace = createTempDir("deepcode-report-failure-workspace-"); + const home = createTempDir("deepcode-report-failure-home-"); + setHomeDir(home); - globalThis.fetch = (async () => ({ - ok: true, - text: async () => "" - }) as Response) as typeof fetch; + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + globalThis.fetch = (async () => { + throw new Error("fetch failed"); + }) as typeof fetch; - const manager = createSessionManager(workspace, "machine-id-pending-tool"); + const manager = createSessionManager(workspace, "machine-id-failure"); (manager as any).activateSession = async () => {}; - const sessionId = await manager.createSession({ text: "first prompt" }); - const assistantMessage = (manager as any).buildAssistantMessage( - sessionId, - "I will run a tool.", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: "{\"command\":\"sleep 100\"}" } - } - ], - "" - ) as SessionMessage; - (manager as any).appendSessionMessage(sessionId, assistantMessage); - - await manager.replySession(sessionId, { text: "second prompt" }); + await manager.createSession({ text: "hello world" }); + await flushPromises(); - const messages = manager.listSessionMessages(sessionId); - const assistantIndex = messages.findIndex((message) => message.id === assistantMessage.id); - assert.notEqual(assistantIndex, -1); - assert.equal(messages[assistantIndex + 1]?.role, "tool"); - assert.equal((messages[assistantIndex + 1]?.messageParams as any)?.tool_call_id, "call-1"); - assert.match(String(messages[assistantIndex + 1]?.content), /Previous tool call did not complete/); - assert.equal(messages[assistantIndex + 2]?.role, "user"); - assert.equal(messages[assistantIndex + 2]?.content, "second prompt"); + assert.deepEqual(warnings, []); }); -test("SessionManager accumulates response usage while active tokens track the latest response", async () => { - const workspace = createTempDir("deepcode-usage-workspace-"); - const home = createTempDir("deepcode-usage-home-"); - process.env.HOME = home; +test( + "SessionManager notifies successful completion with session context", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-notify-success-workspace-"); + const home = createTempDir("deepcode-notify-success-home-"); + setHomeDir(home); + + const notifyOutput = path.join(workspace, "notify.jsonl"); + const notifyScript = createNotifyRecorderScript(workspace); + const manager = createNotifyingSessionManager( + workspace, + [createChatResponse("final answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + notifyScript, + notifyOutput + ); + + await manager.createSession({ text: "notify success" }); + + const records = await waitForNotifyRecords(notifyOutput, 1); + assert.equal(records[0]?.STATUS, "completed"); + assert.equal(records[0]?.FAIL_REASON, null); + assert.equal(records[0]?.BODY, "final answer"); + assert.equal(records[0]?.TITLE, "notify success"); + assert.match(String(records[0]?.DURATION), /^\d+$/); + } +); + +test( + "SessionManager notifies failed completion with failure context", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-notify-failure-workspace-"); + const home = createTempDir("deepcode-notify-failure-home-"); + setHomeDir(home); + + const notifyOutput = path.join(workspace, "notify.jsonl"); + const notifyScript = createNotifyRecorderScript(workspace); + const manager = createNotifyingSessionManager( + workspace, + [ + createChatResponse("first answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + new Error("second request failed"), + ], + notifyScript, + notifyOutput + ); + + const sessionId = await manager.createSession({ text: "notify failure" }); + await waitForNotifyRecords(notifyOutput, 1); + await manager.replySession(sessionId, { text: "second prompt" }); + + const records = await waitForNotifyRecords(notifyOutput, 2); + const failedRecord = records[1]; + assert.equal(failedRecord?.STATUS, "failed"); + assert.equal(failedRecord?.FAIL_REASON, "second request failed"); + assert.equal(failedRecord?.BODY, "first answer"); + assert.notEqual(failedRecord?.BODY, "stale-body"); + assert.equal(failedRecord?.TITLE, "notify failure"); + } +); - const responses = [ - createChatResponse("first", { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { cached_tokens: 7 }, - completion_tokens_details: { reasoning_tokens: 3 }, - prompt_cache_hit_tokens: 7, - prompt_cache_miss_tokens: 3 - }), - createChatResponse("second", { - prompt_tokens: 20, - completion_tokens: 7, - total_tokens: 27, - prompt_tokens_details: { cached_tokens: 11 }, - completion_tokens_details: { reasoning_tokens: 4 }, - prompt_cache_hit_tokens: 11, - prompt_cache_miss_tokens: 9 - }) - ]; - const manager = createMockedClientSessionManager(workspace, responses); +test("replySession continues without appending /continue as a user message", async () => { + const workspace = createTempDir("deepcode-continue-workspace-"); + const home = createTempDir("deepcode-continue-home-"); + setHomeDir(home); - const sessionId = await manager.createSession({ text: "" }); - await manager.replySession(sessionId, { text: "" }); + const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + fetchCalls.push({ input, init }); + return { + ok: true, + text: async () => "", + } as Response; + }) as typeof fetch; - const session = manager.getSession(sessionId); + const manager = createSessionManager(workspace, "machine-id-continue"); + const activatedSessionIds: string[] = []; + (manager as any).activateSession = async (sessionId: string) => { + activatedSessionIds.push(sessionId); + }; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await flushPromises(); + const messagesBefore = manager.listSessionMessages(sessionId); + fetchCalls.length = 0; + activatedSessionIds.length = 0; + + await manager.replySession(sessionId, { text: "/continue" }); + await flushPromises(); + + const messagesAfter = manager.listSessionMessages(sessionId); + const userMessages = messagesAfter.filter((message) => message.role === "user"); + + assert.equal(activatedSessionIds.length, 1); + assert.equal(activatedSessionIds[0], sessionId); + assert.equal(messagesAfter.length, messagesBefore.length); + assert.equal( + userMessages.some((message) => message.content === "/continue"), + false + ); + assert.equal(fetchCalls.length, 0); +}); + +test("replySession records the current file-history branch head as checkpointHash", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-checkpoint-hash-workspace-"); + const home = createTempDir("deepcode-checkpoint-hash-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-checkpoint-hash"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "note.txt": "checkpoint\n" }); + + await manager.replySession(sessionId, { text: "second prompt" }); + + const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user"); + assert.equal(userMessages[userMessages.length - 1]?.checkpointHash, checkpointHash); +}); + +test("createSession initializes file-history repo and session branch", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-file-history-init-workspace-"); + const home = createTempDir("deepcode-file-history-init-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-file-history-init"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + const gitDir = path.join( + home, + ".deepcode", + "projects", + workspace.replace(/[\\/]/g, "-").replace(/:/g, ""), + "file-history", + ".git" + ); + + assert.ok(fs.existsSync(gitDir)); + assert.ok(userMessage?.checkpointHash); + assert.equal( + runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `refs/heads/${sessionId}^{commit}`]).trim(), + userMessage.checkpointHash + ); +}); + +test("Write tool advances file-history while preserving the user prompt checkpoint", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-write-checkpoint-workspace-"); + const home = createTempDir("deepcode-write-checkpoint-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "index.html"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-index", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: filePath, content: "

Hello

\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an index page" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + assert.equal(fs.existsSync(filePath), true); + + manager.restoreSessionCode(sessionId, userMessage.id); + + assert.equal(fs.existsSync(filePath), false); +}); + +test("Write checkpoints restore tool-touched files outside the workspace and leave unrelated files alone", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-write-outside-workspace-"); + const outsideDir = createTempDir("deepcode-write-outside-target-"); + const home = createTempDir("deepcode-write-outside-home-"); + setHomeDir(home); + + const outsideFilePath = path.join(outsideDir, "outside.txt"); + const unrelatedWorkspaceFilePath = path.join(workspace, "unrelated.txt"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-outside", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: outsideFilePath, content: "outside\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an outside file" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + assert.equal(fs.readFileSync(outsideFilePath, "utf8"), "outside\n"); + + fs.writeFileSync(unrelatedWorkspaceFilePath, "keep\n", "utf8"); + manager.restoreSessionCode(sessionId, userMessage.id); + + assert.equal(fs.existsSync(outsideFilePath), false); + assert.equal(fs.readFileSync(unrelatedWorkspaceFilePath, "utf8"), "keep\n"); +}); + +test("missing git executable does not block sessions or Write tool calls", async () => { + const workspace = createTempDir("deepcode-no-git-write-workspace-"); + const home = createTempDir("deepcode-no-git-write-home-"); + setHomeDir(home); + + const originalPath = process.env.PATH; + process.env.PATH = ""; + try { + const filePath = path.join(workspace, "index.html"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-no-git", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: filePath, content: "

No Git

\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an index page" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + + assert.equal(fs.readFileSync(filePath, "utf8"), "

No Git

\n"); + assert.equal(userMessage?.checkpointHash, undefined); + assert.equal(manager.getSession(sessionId)?.status, "completed"); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } +}); + +test("restoreSessionConversation truncates messages before the selected user prompt", async () => { + const workspace = createTempDir("deepcode-undo-conversation-workspace-"); + const home = createTempDir("deepcode-undo-conversation-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-undo-conversation"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const firstAssistant = (manager as any).buildAssistantMessage( + sessionId, + "first answer", + null, + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, firstAssistant); + await manager.replySession(sessionId, { text: "second prompt" }); + const secondUserMessage = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "user") + .at(-1); + assert.ok(secondUserMessage); + const secondAssistant = (manager as any).buildAssistantMessage( + sessionId, + "second answer", + null, + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, secondAssistant); + + manager.restoreSessionConversation(sessionId, secondUserMessage.id); + + const contents = manager.listSessionMessages(sessionId).map((message) => message.content); + assert.ok(contents.includes("first prompt")); + assert.ok(contents.includes("first answer")); + assert.ok(!contents.includes("second prompt")); + assert.ok(!contents.includes("second answer")); + assert.equal(manager.getSession(sessionId)?.assistantReply, "first answer"); +}); + +test("restoreSessionCode restores project files from the recorded Git checkpoint", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-undo-code-workspace-"); + const home = createTempDir("deepcode-undo-code-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-undo-code"); + const sessionId = "session-code-restore"; + const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "before\n" }); + createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "after\n", "new.txt": "remove me\n" }); + fs.writeFileSync(path.join(workspace, "tracked.txt"), "after\n", "utf8"); + fs.writeFileSync(path.join(workspace, "new.txt"), "remove me\n", "utf8"); + + (manager as any).appendSessionMessage(sessionId, { + ...buildTestMessage("user-with-checkpoint", sessionId, "user", "restore here"), + checkpointHash, + }); + + manager.restoreSessionCode(sessionId, "user-with-checkpoint"); + + assert.equal(fs.readFileSync(path.join(workspace, "tracked.txt"), "utf8"), "before\n"); + assert.equal(fs.existsSync(path.join(workspace, "new.txt")), false); +}); + +test("replySession /continue runs trailing pending tool calls before requesting another response", async () => { + const workspace = createTempDir("deepcode-continue-tool-workspace-"); + const home = createTempDir("deepcode-continue-tool-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("continued after tool", { + prompt_tokens: 9, + completion_tokens: 2, + total_tokens: 11, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const pendingAssistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read a file", + [ + { + id: "call-pending-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + fs.writeFileSync(path.join(workspace, "note.txt"), "hello from pending tool\n", "utf8"); + (manager as any).appendSessionMessage(sessionId, pendingAssistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { text: "/continue" }); + + const messages = manager.listSessionMessages(sessionId); + const toolMessage = messages.find((message) => { + const params = message.messageParams as { tool_call_id?: string } | null; + return message.role === "tool" && params?.tool_call_id === "call-pending-read"; + }); + const assistantMessages = messages.filter((message) => message.role === "assistant"); + const userMessages = messages.filter((message) => message.role === "user"); + + assert.ok(toolMessage); + assert.match(toolMessage.content ?? "", /hello from pending tool/); + assert.equal(assistantMessages[assistantMessages.length - 1]?.content, "continued after tool"); + assert.equal( + userMessages.some((message) => message.content === "/continue"), + false + ); +}); + +test("activateSession pauses for permission when a tool call requires ask", async () => { + const workspace = createTempDir("deepcode-permission-ask-workspace-"); + const home = createTempDir("deepcode-permission-ask-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rg TODO src", + description: "Search TODO markers", + sideEffects: ["read-in-cwd"], + }), + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ], + { + allow: [], + deny: [], + ask: [], + defaultMode: "askAll", + } + ); + + const sessionId = await manager.createSession({ text: "search todos" }); + const session = manager.getSession(sessionId); + const assistant = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + + assert.equal(session?.status, "ask_permission"); + assert.equal(session?.askPermissions?.[0]?.toolCallId, "call-bash"); + assert.deepEqual(session?.askPermissions?.[0]?.scopes, ["read-in-cwd"]); + assert.deepEqual(assistant?.meta?.permissions, [{ toolCallId: "call-bash", permission: "ask" }]); + assert.equal( + manager.listSessionMessages(sessionId).some((message) => message.role === "tool"), + false + ); +}); + +test("SessionManager preserves permission_denied status when sessions are reloaded", async () => { + const workspace = createTempDir("deepcode-permission-denied-workspace-"); + const home = createTempDir("deepcode-permission-denied-home-"); + setHomeDir(home); + + const permissions = { + allow: [], + deny: [], + ask: [], + defaultMode: "askAll" as const, + }; + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rg TODO src", + description: "Search TODO markers", + sideEffects: ["read-in-cwd"], + }), + }, + }, + ], + }, + }, + ], + }, + ], + permissions + ); + + const sessionId = await manager.createSession({ text: "search todos" }); + manager.denySessionPermission(sessionId); + + const reloadedManager = createPermissionSessionManager(workspace, [], permissions); + const reloadedSession = reloadedManager.getSession(sessionId); + + assert.equal(reloadedSession?.status, "permission_denied"); + assert.equal(reloadedSession?.failReason, "Permission denied by user"); +}); + +test("replySession applies permission replies, runs pending tools, and stores always allow scopes", async () => { + const workspace = createTempDir("deepcode-permission-allow-workspace-"); + const home = createTempDir("deepcode-permission-allow-home-"); + setHomeDir(home); + fs.writeFileSync(path.join(workspace, "note.txt"), "allowed content\n", "utf8"); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("continued", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["read-in-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read", + [ + { + id: "call-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-read", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "/continue", + permissions: [{ toolCallId: "call-read", permission: "allow" }], + alwaysAllows: ["read-in-cwd"], + }); + + const toolMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "tool"); + const settings = JSON.parse(fs.readFileSync(path.join(workspace, ".deepcode", "settings.json"), "utf8")); + + assert.match(toolMessage?.content ?? "", /allowed content/); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd"]); + assert.equal(manager.getSession(sessionId)?.status, "completed"); +}); + +test("replySession turns denied permission replies into tool errors before appending user text", async () => { + const workspace = createTempDir("deepcode-permission-deny-workspace-"); + const home = createTempDir("deepcode-permission-deny-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("handled denial", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["write-out-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to write", + [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/outside.txt", content: "x" }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-write", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "Do not write outside the workspace.", + permissions: [{ toolCallId: "call-write", permission: "deny" }], + }); + + const messages = manager.listSessionMessages(sessionId); + const assistantIndex = messages.findIndex((message) => message.id === assistant.id); + const toolMessage = messages[assistantIndex + 1]; + const userMessage = messages[assistantIndex + 2]; + + assert.equal(toolMessage?.role, "tool"); + assert.match(toolMessage?.content ?? "", /User denied the required permission/); + assert.equal(userMessage?.role, "user"); + assert.equal(userMessage?.content, "Do not write outside the workspace."); +}); + +test("replySession preserves raw session messages when a previous tool call is pending", async () => { + const workspace = createTempDir("deepcode-pending-tool-workspace-"); + const home = createTempDir("deepcode-pending-tool-home-"); + setHomeDir(home); + + globalThis.fetch = (async () => + ({ + ok: true, + text: async () => "", + }) as Response) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-pending-tool"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistantMessage = (manager as any).buildAssistantMessage( + sessionId, + "I will run a tool.", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"sleep 100"}' }, + }, + ], + "" + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, assistantMessage); + + await manager.replySession(sessionId, { text: "second prompt" }); + + const messages = manager.listSessionMessages(sessionId); + const assistantIndex = messages.findIndex((message) => message.id === assistantMessage.id); + assert.notEqual(assistantIndex, -1); + assert.equal(messages[assistantIndex + 1]?.role, "user"); + assert.equal(messages[assistantIndex + 1]?.content, "second prompt"); + assert.equal( + messages.some((message) => String(message.content).includes("Previous tool call did not complete.")), + false + ); +}); + +test("buildOpenAIMessages inserts interrupted results for missing tool messages", () => { + const manager = createSessionManager(process.cwd(), "machine-id-missing-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "I will run a tool.", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"sleep 100"}' }, + }, + ], + "" + ) as SessionMessage; + const userMessage = buildTestMessage("user-after-tool-call", "session-1", "user", "continue"); + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, userMessage], + false, + "test-model" + ) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.equal(openAIMessages.length, 3); + assert.equal(openAIMessages[0]?.role, "assistant"); + assert.equal(openAIMessages[1]?.role, "tool"); + assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); + assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); + assert.equal(openAIMessages[2]?.role, "user"); +}); + +test("buildOpenAIMessages keeps only the first non-interrupted tool result for a tool call", () => { + const manager = createSessionManager(process.cwd(), "machine-id-duplicate-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + const successToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "2026-05-07 星期四\n" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + const interruptedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, successToolMessage, interruptedToolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const toolMessages = openAIMessages.filter((message) => message.role === "tool"); + + assert.equal(toolMessages.length, 1); + assert.equal(toolMessages[0]?.tool_call_id, "call-1"); + assert.match(toolMessages[0]?.content ?? "", /2026-05-07/); + assert.doesNotMatch(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); +}); + +test("buildOpenAIMessages prefers a later real tool result over an earlier interrupted placeholder", () => { + const manager = createSessionManager(process.cwd(), "machine-id-prefer-real-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + const interruptedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + const successToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "real result" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, interruptedToolMessage, successToolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const toolMessages = openAIMessages.filter((message) => message.role === "tool"); + + assert.equal(toolMessages.length, 1); + assert.equal(toolMessages[0]?.tool_call_id, "call-1"); + assert.match(toolMessages[0]?.content ?? "", /real result/); +}); + +test("buildOpenAIMessages ignores orphan tool messages", () => { + const manager = createSessionManager(process.cwd(), "machine-id-orphan-tool"); + const userMessage = buildTestMessage("user-1", "session-1", "user", "hello"); + const orphanToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-orphan", + JSON.stringify({ ok: true, name: "bash", output: "orphan" }), + { name: "bash", arguments: '{"command":"echo orphan"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [userMessage, orphanToolMessage], + false, + "test-model" + ) as Array<{ + role: string; + }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["user"] + ); +}); + +test("buildOpenAIMessages moves a later paired tool message behind its assistant", () => { + const manager = createSessionManager(process.cwd(), "machine-id-later-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + const userMessage = buildTestMessage("user-between", "session-1", "user", "continue"); + const toolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "paired later" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, userMessage, toolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "user"] + ); + assert.match(openAIMessages[1]?.content ?? "", /paired later/); +}); + +test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { + const manager = createSessionManager(process.cwd(), "machine-id-multi-tool-happy"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' }, + }, + { + id: "call-2", + type: "function", + function: { name: "bash", arguments: '{"command":"pwd"}' }, + }, + ], + "" + ) as SessionMessage; + const firstToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "read", content: "file content" }), + { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } + ) as SessionMessage; + const secondToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-2", + JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), + { name: "bash", arguments: '{"command":"pwd"}' } + ) as SessionMessage; + const userMessage = buildTestMessage("user-after-complete-tools", "session-1", "user", "thanks"); + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, firstToolMessage, secondToolMessage, userMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + openAIMessages.filter((message) => message.role === "tool").map((message) => message.tool_call_id), + ["call-1", "call-2"] + ); + assert.equal( + openAIMessages.some((message) => message.content.includes("Previous tool call did not complete.")), + false + ); +}); + +test("buildOpenAIMessages preserves a real failed tool result", () => { + const manager = createSessionManager(process.cwd(), "machine-id-real-failed-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"false"}' }, + }, + ], + "" + ) as SessionMessage; + const failedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), + { name: "bash", arguments: '{"command":"false"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, failedToolMessage], + false, + "test-model" + ) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool"] + ); + assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); + assert.match(openAIMessages[1]?.content ?? "", /Command failed/); + assert.doesNotMatch(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); +}); + +test("UpdatePlan tool params only show explanation when provided", () => { + const manager = createSessionManager(process.cwd(), "machine-id-update-plan-params"); + const plan = "## Task List\n\n- [ ] Inspect project"; + + const withExplanation = (manager as any).buildToolMessage( + "session-1", + "call-plan-1", + JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), + { name: "UpdatePlan", arguments: JSON.stringify({ plan, explanation: "Start planning" }) } + ) as SessionMessage; + const withoutExplanation = (manager as any).buildToolMessage( + "session-1", + "call-plan-2", + JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), + { name: "UpdatePlan", arguments: JSON.stringify({ plan }) } + ) as SessionMessage; + + assert.equal(withExplanation.meta?.paramsMd, "Start planning"); + assert.equal(withoutExplanation.meta?.paramsMd, ""); +}); + +test("Write tool params prefer file_path even when content appears first", () => { + const manager = createSessionManager(process.cwd(), "machine-id-write-params"); + const filePath = path.join(process.cwd(), "index.html"); + + const toolMessage = (manager as any).buildToolMessage( + "session-1", + "call-write-1", + JSON.stringify({ ok: true, name: "write", output: "Created file." }), + { + name: "write", + arguments: JSON.stringify({ + content: "// === entry ===\nconsole.log('demo');\n", + file_path: filePath, + }), + } + ) as SessionMessage; + + assert.equal(toolMessage.meta?.paramsMd, filePath); +}); + +test("LLM tool calls without ids receive generated 32 character ids", async () => { + const workspace = createTempDir("deepcode-tool-call-id-workspace-"); + const home = createTempDir("deepcode-tool-call-id-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "note.txt"); + fs.writeFileSync(filePath, "hello\n", "utf8"); + const plan = "## Task List\n\n- [ ] Inspect current behavior"; + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "", + type: "function", + function: { + name: "UpdatePlan", + arguments: JSON.stringify({ plan, explanation: "Initial plan" }), + }, + }, + { + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ file_path: filePath }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "inspect note" }); + const assistantMessage = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + const toolCalls = (assistantMessage?.messageParams as { tool_calls?: Array<{ id?: unknown }> } | null)?.tool_calls; + + assert.equal(toolCalls?.length, 2); + assert.match(String(toolCalls?.[0]?.id), /^[0-9a-f]{32}$/); + assert.match(String(toolCalls?.[1]?.id), /^[0-9a-f]{32}$/); + assert.notEqual(toolCalls?.[0]?.id, toolCalls?.[1]?.id); + + const toolMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "tool"); + assert.deepEqual( + toolMessages.map((message) => (message.messageParams as { tool_call_id?: unknown } | null)?.tool_call_id), + toolCalls?.map((toolCall) => toolCall.id) + ); + + const readToolMessage = toolMessages.find((message) => JSON.parse(message.content ?? "{}").name === "read"); + assert.equal((readToolMessage?.meta?.function as { name?: string } | undefined)?.name, "read"); + assert.equal(readToolMessage?.meta?.paramsMd, "note.txt"); +}); + +test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { + const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' }, + }, + { + id: "call-2", + type: "function", + function: { name: "bash", arguments: '{"command":"pwd"}' }, + }, + ], + "" + ) as SessionMessage; + const orphanToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-orphan", + JSON.stringify({ ok: true, name: "bash", output: "orphan" }), + { name: "bash", arguments: '{"command":"echo orphan"}' } + ) as SessionMessage; + const pairedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-2", + JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), + { name: "bash", arguments: '{"command":"pwd"}' } + ) as SessionMessage; + const duplicateToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-2", + JSON.stringify({ ok: true, name: "bash", output: "duplicate" }), + { name: "bash", arguments: '{"command":"pwd"}' } + ) as SessionMessage; + const userMessage = buildTestMessage("user-after-mixed-tools", "session-1", "user", "continue"); + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, orphanToolMessage, pairedToolMessage, duplicateToolMessage, userMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const toolMessages = openAIMessages.filter((message) => message.role === "tool"); + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + toolMessages.map((message) => message.tool_call_id), + ["call-1", "call-2"] + ); + assert.match(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); + assert.match(toolMessages[1]?.content ?? "", /\/tmp/); + assert.equal( + openAIMessages.some((message) => message.content.includes("orphan")), + false + ); + assert.equal( + openAIMessages.some((message) => message.content.includes("duplicate")), + false + ); +}); + +test("buildOpenAIMessages ignores tool messages that appear before their assistant", () => { + const manager = createSessionManager(process.cwd(), "machine-id-tool-before-assistant"); + const earlyToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "too early" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [earlyToolMessage, assistantMessage], + false, + "test-model" + ) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool"] + ); + assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); + assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); + assert.doesNotMatch(openAIMessages[1]?.content ?? "", /too early/); +}); + +test("SessionManager accumulates response usage while active tokens track the latest response", async () => { + const workspace = createTempDir("deepcode-usage-workspace-"); + const home = createTempDir("deepcode-usage-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("first", { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + prompt_tokens_details: { cached_tokens: 7 }, + completion_tokens_details: { reasoning_tokens: 3 }, + prompt_cache_hit_tokens: 7, + prompt_cache_miss_tokens: 3, + }), + createChatResponse("second", { + prompt_tokens: 20, + completion_tokens: 7, + total_tokens: 27, + prompt_tokens_details: { cached_tokens: 11 }, + completion_tokens_details: { reasoning_tokens: 4 }, + prompt_cache_hit_tokens: 11, + prompt_cache_miss_tokens: 9, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "" }); + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); const usage = session?.usage as Record; + const usagePerModel = session?.usagePerModel?.["test-model"] as Record; assert.equal(session?.activeTokens, 27); assert.equal(usage.prompt_tokens, 30); assert.equal(usage.completion_tokens, 12); @@ -432,29 +2028,98 @@ test("SessionManager accumulates response usage while active tokens track the la assert.equal(usage.completion_tokens_details.reasoning_tokens, 7); assert.equal(usage.prompt_cache_hit_tokens, 18); assert.equal(usage.prompt_cache_miss_tokens, 12); + assert.equal(usagePerModel.prompt_tokens, 30); + assert.equal(usagePerModel.completion_tokens, 12); + assert.equal(usagePerModel.total_tokens, 42); + assert.equal(usagePerModel.prompt_tokens_details.cached_tokens, 18); + assert.equal(usagePerModel.completion_tokens_details.reasoning_tokens, 7); + assert.equal(usagePerModel.prompt_cache_hit_tokens, 18); + assert.equal(usagePerModel.prompt_cache_miss_tokens, 12); + assert.equal(usagePerModel.total_reqs, 2); +}); + +test("SessionManager stores usage per model across model changes", async () => { + const workspace = createTempDir("deepcode-usage-per-model-workspace-"); + const home = createTempDir("deepcode-usage-per-model-home-"); + setHomeDir(home); + + let currentModel = "deepseek-v4-pro"; + const responses = [ + createChatResponse("pro response", { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }), + createChatResponse("flash response", { + prompt_tokens: 20, + completion_tokens: 7, + total_tokens: 27, + prompt_cache_hit_tokens: 6, + }), + ]; + const client = { + chat: { + completions: { + create: async () => { + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: client as any, + model: currentModel, + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: currentModel }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const sessionId = await manager.createSession({ text: "" }); + currentModel = "deepseek-v4-flash"; + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); + assert.deepEqual(Object.keys(session?.usagePerModel ?? {}).sort(), ["deepseek-v4-flash", "deepseek-v4-pro"]); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.prompt_tokens, 10); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.completion_tokens, 5); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.total_reqs, 1); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_tokens, 20); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.completion_tokens, 7); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_cache_hit_tokens, 6); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.total_reqs, 1); + assert.equal(session?.usage?.prompt_tokens, 30); + assert.equal(session?.usage?.completion_tokens, 12); + assert.equal(session?.usage?.total_tokens, 42); }); test("SessionManager resets active tokens to latest post-compaction response usage", async () => { const workspace = createTempDir("deepcode-compact-usage-workspace-"); const home = createTempDir("deepcode-compact-usage-home-"); - process.env.HOME = home; + setHomeDir(home); const responses = [ createChatResponse("large", { prompt_tokens: 139_990, completion_tokens: 10, - total_tokens: 140_000 + total_tokens: 140_000, }), createChatResponse("summary", { prompt_tokens: 100, completion_tokens: 23, - total_tokens: 123 + total_tokens: 123, }), createChatResponse("after compact", { prompt_tokens: 5, completion_tokens: 2, - total_tokens: 7 - }) + total_tokens: 7, + }), ]; const manager = createMockedClientSessionManager(workspace, responses); @@ -465,16 +2130,21 @@ test("SessionManager resets active tokens to latest post-compaction response usa const session = manager.getSession(sessionId); const usage = session?.usage as Record; + const usagePerModel = session?.usagePerModel?.["test-model"] as Record; assert.equal(session?.activeTokens, 7); assert.equal(usage.prompt_tokens, 140_095); assert.equal(usage.completion_tokens, 35); assert.equal(usage.total_tokens, 140_130); + assert.equal(usagePerModel.prompt_tokens, 140_095); + assert.equal(usagePerModel.completion_tokens, 35); + assert.equal(usagePerModel.total_tokens, 140_130); + assert.equal(usagePerModel.total_reqs, 3); }); test("SessionManager streams chat completions and counts reasoning progress", async () => { const workspace = createTempDir("deepcode-stream-workspace-"); const home = createTempDir("deepcode-stream-home-"); - process.env.HOME = home; + setHomeDir(home); const progressEvents: Array<{ phase: string; @@ -495,13 +2165,13 @@ test("SessionManager streams chat completions and counts reasoning progress", as usage: { prompt_tokens: 2, completion_tokens: 3, - total_tokens: 5 - } - } + total_tokens: 5, + }, + }, ]); - } - } - } + }, + }, + }, }; const manager = new SessionManager({ @@ -510,24 +2180,22 @@ test("SessionManager streams chat completions and counts reasoning progress", as client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onLlmStreamProgress: (progress) => { progressEvents.push({ phase: progress.phase, estimatedTokens: progress.estimatedTokens, - formattedTokens: progress.formattedTokens + formattedTokens: progress.formattedTokens, }); - } + }, }); const sessionId = await manager.createSession({ text: "" }); - const assistantMessage = manager - .listSessionMessages(sessionId) - .find((message) => message.role === "assistant"); + const assistantMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "assistant"); assert.equal(assistantMessage?.content, "hello"); assert.equal((assistantMessage?.messageParams as any)?.reasoning_content, "思考"); @@ -540,19 +2208,16 @@ test("SessionManager streams chat completions and counts reasoning progress", as assert.equal(progressEvents[2]?.formattedTokens, "3"); }); -test("SessionManager cancels skill matching before a session is created", async () => { +test("SessionManager persists session and user message before skill matching is cancelled", async () => { const workspace = createTempDir("deepcode-skill-abort-workspace-"); const home = createTempDir("deepcode-skill-abort-home-"); - process.env.HOME = home; + setHomeDir(home); const skillDir = path.join(home, ".agents", "skills", "demo"); fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - "---\nname: demo\ndescription: Demo skill\n---\n# Demo\n", - "utf8" - ); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: demo\ndescription: Demo skill\n---\n# Demo\n", "utf8"); + // eslint-disable-next-line prefer-const -- must be declared before client which references it let manager: SessionManager; const client = { chat: { @@ -563,22 +2228,28 @@ test("SessionManager cancels skill matching before a session is created", async signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); queueMicrotask(() => manager.interruptActiveSession()); }); - } - } - } + }, + }, + }, }; manager = createMockedClientSessionManagerWithClient(workspace, client); await manager.handleUserPrompt({ text: "please use demo" }); - assert.equal(manager.listSessions().length, 0); + // Session and user message are persisted before skill matching triggers an abort. + assert.equal(manager.listSessions().length, 1); + const [session] = manager.listSessions(); + assert.equal(session?.status, "pending"); + const messages = manager.listSessionMessages(session!.id); + const userMessage = messages.find((m) => m.role === "user"); + assert.equal(userMessage?.content, "please use demo"); }); test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { const workspace = createTempDir("deepcode-api-abort-workspace-"); const home = createTempDir("deepcode-api-abort-home-"); - process.env.HOME = home; + setHomeDir(home); let manager: SessionManager; const client = { @@ -589,27 +2260,28 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = const signal = options?.signal; signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); }); - } - } - } + }, + }, + }, }; + // eslint-disable-next-line prefer-const -- declared before client, assigned after manager = new SessionManager({ projectRoot: workspace, createOpenAIClient: () => ({ client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onSessionEntryUpdated: (entry) => { if (entry.status === "processing") { queueMicrotask(() => manager.interruptActiveSession()); } - } + }, }); await manager.handleUserPrompt({ text: "" }); @@ -621,6 +2293,278 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = assert.equal(session?.failReason, "interrupted"); }); +test("SessionManager marks MCP server as failed on single failed attempt (no auto-retry)", async () => { + const workspace = createTempDir("deepcode-mcp-fail-noworkspace-"); + const serverPath = path.join(workspace, "mcp-server-fail.cjs"); + fs.writeFileSync(serverPath, "process.exit(7);", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-mcp-fail-no"); + await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus(); + assert.equal(status.length, 1); + assert.equal(status[0]?.status, "failed"); + assert.match(status[0]?.error ?? "", /exited with code 7/); + + manager.dispose(); +}); + +test("SessionManager reconnect succeeds on previously failed server", async () => { + const workspace = createTempDir("deepcode-mcp-reconn-ok-workspace-"); + const serverPath = path.join(workspace, "mcp-server-ok.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) return; + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: {} } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "ping", inputSchema: { type: "object", properties: {} } }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-reconn-ok"); + await manager.initMcpServers({ fixable: { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus(); + assert.equal(status.length, 1); + assert.equal(status[0]?.status, "ready"); + assert.equal(status[0]?.toolCount, 1); + + manager.dispose(); +}); + +test("SessionManager adjusts the active Bash timeout control and session metadata", async () => { + const workspace = createTempDir("deepcode-bash-timeout-session-"); + const home = createTempDir("deepcode-bash-timeout-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, ""); + const sessionId = await manager.createSession({ text: "hello" }); + + (manager as any).addSessionProcess(sessionId, 123, "sleep 10"); + + let timeoutInfo = { + timeoutMs: 10 * 60 * 1000, + startedAtMs: 1000, + deadlineAtMs: 1000 + 10 * 60 * 1000, + timedOut: false, + }; + (manager as any).setSessionProcessTimeoutControl(sessionId, 123, { + getInfo: () => timeoutInfo, + setTimeoutMs: (timeoutMs: number) => { + timeoutInfo = { + ...timeoutInfo, + timeoutMs, + deadlineAtMs: timeoutInfo.startedAtMs + timeoutMs, + }; + return timeoutInfo; + }, + }); + + const adjustment = manager.adjustActiveBashTimeout(5 * 60 * 1000); + const processInfo = manager.getSession(sessionId)?.processes?.get("123"); + + assert.equal(adjustment?.processId, "123"); + assert.equal(adjustment?.timeoutMs, 15 * 60 * 1000); + assert.equal(processInfo?.timeoutMs, 15 * 60 * 1000); + assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString()); +}); + +test("SessionManager.deleteSession removes session entry from the index", () => { + const workspace = createTempDir("deepcode-delete-workspace-"); + const home = createTempDir("deepcode-delete-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete"); + (manager as any).activateSession = async () => {}; + + // Create two sessions + const session1 = createSessionAndMessages(manager, "session-delete-1", "First session"); + const session2 = createSessionAndMessages(manager, "session-delete-2", "Second session"); + + assert.equal(manager.listSessions().length, 2); + + // Delete the first session + const result = manager.deleteSession(session1); + assert.equal(result, true); + + const remaining = manager.listSessions(); + assert.equal(remaining.length, 1); + assert.equal(remaining[0]?.id, session2); +}); + +test("SessionManager.deleteSession removes the messages file", () => { + const workspace = createTempDir("deepcode-delete-msg-workspace-"); + const home = createTempDir("deepcode-delete-msg-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-msg"); + (manager as any).activateSession = async () => {}; + + const sessionId = createSessionAndMessages(manager, "session-delete-msg", "Test session"); + const messagePath = path.join( + home, + ".deepcode", + "projects", + workspace.replace(/[\\\\/]/g, "-").replace(/:/g, ""), + `${sessionId}.jsonl` + ); + + // Verify messages file exists + assert.ok(fs.existsSync(messagePath)); + + manager.deleteSession(sessionId); + + // Verify messages file is removed + assert.equal(fs.existsSync(messagePath), false); +}); + +test("SessionManager.deleteSession returns false when session does not exist", () => { + const workspace = createTempDir("deepcode-delete-nonexist-workspace-"); + const home = createTempDir("deepcode-delete-nonexist-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-nonexist"); + + const result = manager.deleteSession("nonexistent-session-id"); + assert.equal(result, false); + assert.equal(manager.listSessions().length, 0); +}); + +test("SessionManager.deleteSession does not affect other sessions", () => { + const workspace = createTempDir("deepcode-delete-others-workspace-"); + const home = createTempDir("deepcode-delete-others-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-others"); + (manager as any).activateSession = async () => {}; + + const session1 = createSessionAndMessages(manager, "session-keep-1", "Keep session 1"); + const session2 = createSessionAndMessages(manager, "session-keep-2", "Keep session 2"); + + // Delete non-existent session + const result = manager.deleteSession("non-existent"); + assert.equal(result, false); + assert.equal(manager.listSessions().length, 2); + + // Delete one session + assert.equal(manager.deleteSession(session1), true); + assert.equal(manager.listSessions().length, 1); + assert.equal(manager.listSessions()[0]?.id, session2); + + // The remaining session should still have its messages accessible + const messages = manager.listSessionMessages(session2); + assert.ok(messages.length > 0); +}); + +/** + * Helper: creates a session and writes a few messages to it so we can test + * that deleteSession removes both the index entry and the messages file. + */ +function createSessionAndMessages(manager: SessionManager, sessionId: string, summary: string): string { + const now = new Date().toISOString(); + const index = (manager as any).loadSessionsIndex(); + index.entries.push({ + id: sessionId, + summary, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: now, + updateTime: now, + processes: null, + }); + (manager as any).saveSessionsIndex(index); + + // Write a couple of message lines to the messages file + const projectDir = (manager as any).getProjectStorage().projectDir; + const messagePath = path.join(projectDir, `${sessionId}.jsonl`); + const msg = JSON.stringify({ + id: "msg-1", + sessionId, + role: "user", + content: summary, + visible: true, + createTime: now, + updateTime: now, + }); + fs.writeFileSync(messagePath, `${msg}\n`, "utf8"); + + return sessionId; +} + +function hasGit(): boolean { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function createFileHistoryCommit( + home: string, + workspace: string, + sessionId: string, + files: Record +): string { + const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); + const gitDir = path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git"); + const fileHistory = new GitFileHistory(workspace, gitDir); + fileHistory.ensureSession(sessionId); + + const filePaths: string[] = []; + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(workspace, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); + filePaths.push(filePath); + } + const commitHash = fileHistory.recordCheckpoint(sessionId, filePaths, "checkpoint"); + assert.ok(commitHash); + return commitHash; +} + +function runFileHistoryGit( + gitDir: string, + workspace: string, + args: string[], + input = "", + env: NodeJS.ProcessEnv = process.env +): string { + return execFileSync( + "git", + ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], + { + encoding: "utf8", + input, + env, + stdio: ["pipe", "pipe", "pipe"], + } + ); +} + function createSessionManager(projectRoot: string, machineId: string): SessionManager { return new SessionManager({ projectRoot, @@ -629,11 +2573,54 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa model: "test-model", baseURL: "https://api.deepseek.com", thinkingEnabled: false, - machineId + machineId, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +function createNotifyingSessionManager( + projectRoot: string, + responses: unknown[], + notifyPath: string, + notifyOutput: string +): SessionManager { + const client = { + chat: { + completions: { + create: async () => { + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + if (response instanceof Error) { + throw response; + } + return response; + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + notify: notifyPath, + env: { + NOTIFY_OUTPUT: notifyOutput, + STATUS: "stale-status", + FAIL_REASON: "stale-failure", + BODY: "stale-body", + TITLE: "stale-title", + }, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); } @@ -645,9 +2632,45 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow const response = responses.shift(); assert.ok(response, "expected a queued chat response"); return response; - } - } - } + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +function createPermissionSessionManager( + projectRoot: string, + responses: unknown[], + permissions: { + allow: any[]; + deny: any[]; + ask: any[]; + defaultMode: "allowAll" | "askAll"; + } +): SessionManager { + const client = { + chat: { + completions: { + create: async () => { + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, }; return new SessionManager({ @@ -656,11 +2679,11 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model", permissions }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); } @@ -671,11 +2694,11 @@ function createMockedClientSessionManagerWithClient(projectRoot: string, client: client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false + thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, - onAssistantMessage: () => {} + onAssistantMessage: () => {}, }); } @@ -684,7 +2707,27 @@ class APIUserAbortError extends Error {} function createChatResponse(content: string, usage: Record): unknown { return { choices: [{ message: { content } }], - usage + usage, + }; +} + +function buildTestMessage( + id: string, + sessionId: string, + role: SessionMessage["role"], + content: string +): SessionMessage { + return { + id, + sessionId, + role, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", }; } @@ -700,6 +2743,59 @@ function createTempDir(prefix: string): string { return dir; } +function createNotifyRecorderScript(dir: string): string { + const scriptPath = path.join(dir, "notify-recorder.cjs"); + fs.writeFileSync( + scriptPath, + `#!/usr/bin/env node +const fs = require("fs"); +const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"]; +const record = {}; +for (const key of keys) { + record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null; +} +fs.appendFileSync(process.env.NOTIFY_OUTPUT, JSON.stringify(record) + "\\n", "utf8"); +`, + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); + return scriptPath; +} + +async function waitForNotifyRecords( + outputPath: string, + expectedCount: number +): Promise>> { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(outputPath)) { + const records = fs + .readFileSync(outputPath, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + if (records.length >= expectedCount) { + return records; + } + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`expected ${expectedCount} notify records in ${outputPath}`); +} + +async function waitForMcpStatus(manager: SessionManager, expectedStatus: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (manager.getMcpStatus()[0]?.status === expectedStatus) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`expected MCP status ${expectedStatus}`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + async function flushPromises(): Promise { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts index e6c5bb0..6fe41c7 100644 --- a/src/tests/sessionList.test.ts +++ b/src/tests/sessionList.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { formatSessionTitle } from "../ui/SessionList"; +import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui"; +import type { SessionEntry } from "../session"; test("formatSessionTitle replaces newlines with spaces", () => { assert.equal(formatSessionTitle("first line\nsecond line\r\nthird"), "first line second line third"); @@ -9,3 +10,110 @@ test("formatSessionTitle replaces newlines with spaces", () => { test("formatSessionTitle truncates after normalizing whitespace", () => { assert.equal(formatSessionTitle("one\n two three", 10), "one two th…"); }); + +test("formatSessionStatus maps status values to display labels", () => { + assert.equal(formatSessionStatus("completed"), "done"); + assert.equal(formatSessionStatus("processing"), "running"); + assert.equal(formatSessionStatus("pending"), "pending"); + assert.equal(formatSessionStatus("waiting_for_user"), "waiting"); + assert.equal(formatSessionStatus("failed"), "failed"); + assert.equal(formatSessionStatus("interrupted"), "stopped"); + assert.equal(formatSessionStatus("ask_permission"), "waiting"); + assert.equal(formatSessionStatus("permission_denied"), "denied"); + assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status"); +}); + +test("filterSessions returns all sessions when query is empty", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + assert.equal(filterSessions(sessions, "").length, 2); + assert.equal(filterSessions(sessions, " ").length, 2); +}); + +test("filterSessions matches by summary (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Fix login bug" }, + { summary: "Add dark mode" }, + { summary: "Refactor auth module" }, + ]); + + assert.equal(filterSessions(sessions, "login").length, 1); + assert.equal(filterSessions(sessions, "LOGIN").length, 1); + assert.equal(filterSessions(sessions, "Login").length, 1); +}); + +test("filterSessions matches by status (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "completed" }, + { summary: "Task 2", status: "failed" }, + { summary: "Task 3", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "failed").length, 1); + assert.equal(filterSessions(sessions, "completed").length, 2); +}); + +test("filterSessions matches by failReason", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "failed", failReason: "API key not found" }, + { summary: "Task 2", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "API key").length, 1); + assert.equal(filterSessions(sessions, "not found").length, 1); +}); + +test("filterSessions matches by assistantReply", () => { + const sessions = buildSessions([ + { summary: "Task 1", assistantReply: "The bug was fixed by updating the config." }, + { summary: "Task 2", assistantReply: "Dark mode has been added successfully." }, + ]); + + assert.equal(filterSessions(sessions, "dark mode").length, 1); + assert.equal(filterSessions(sessions, "config").length, 1); +}); + +test("filterSessions returns empty array when no match", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + + assert.equal(filterSessions(sessions, "nonexistent").length, 0); +}); + +test("filterSessions matches across multiple fields on same session", () => { + const sessions = buildSessions([ + { summary: "Fix login bug", status: "failed", failReason: "Timeout error" }, + { summary: "Add dark mode", status: "completed" }, + ]); + + // Should match the first session via status + assert.equal(filterSessions(sessions, "failed").length, 1); + // Should match the first session via failReason + assert.equal(filterSessions(sessions, "timeout").length, 1); + // Partial summary match + assert.equal(filterSessions(sessions, "login").length, 1); +}); + +test("filterSessions handles sessions with null fields", () => { + const sessions = buildSessions([{ summary: null }, { summary: "Valid summary" }]); + + assert.equal(filterSessions(sessions, "valid").length, 1); + assert.equal(filterSessions(sessions, "summary").length, 1); +}); + +function buildSessions(overrides: Array>): SessionEntry[] { + return overrides.map((override, i) => ({ + id: `session-${i}`, + summary: override.summary ?? null, + assistantReply: override.assistantReply ?? null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: override.status ?? "completed", + failReason: override.failReason ?? null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + })); +} diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index ed15d86..52f8671 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,7 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../notify"; -import { resolveSettings } from "../settings"; +import { + buildNotifyEnv, + formatDurationSeconds, + launchNotifyScript, + type NotifyContext, + type NotifySpawn, +} from "../common/notify"; +import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; + +const TEST_PROCESS_ENV = {}; test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( @@ -9,17 +17,19 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool env: { MODEL: "deepseek-v3.2", BASE_URL: "https://example.com/v1", - API_KEY: "sk-test" + API_KEY: "sk-test", }, thinkingEnabled: true, reasoningEffort: "high", + debugLogEnabled: true, notify: " /tmp/notify.sh ", - webSearchTool: " /tmp/web-search.sh " + webSearchTool: " /tmp/web-search.sh ", }, { model: "default-model", - baseURL: "https://default.example.com" - } + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v3.2"); @@ -27,42 +37,209 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool assert.equal(resolved.apiKey, "sk-test"); assert.equal(resolved.thinkingEnabled, true); assert.equal(resolved.reasoningEffort, "high"); + assert.equal(resolved.debugLogEnabled, true); assert.equal(resolved.notify, "/tmp/notify.sh"); assert.equal(resolved.webSearchTool, "/tmp/web-search.sh"); }); -test("resolveSettings still accepts legacy env.THINKING and defaults reasoning effort when absent", () => { +test("resolveSettings gives top-level model priority over env MODEL", () => { const resolved = resolveSettings( { + model: "deepseek-v4-flash", env: { - THINKING: "enabled" - } + MODEL: "deepseek-v4-pro", + }, }, { model: "default-model", - baseURL: "https://default.example.com" - } + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + + assert.equal(resolved.model, "deepseek-v4-flash"); +}); + +test("resolveSettings reads THINKING_ENABLED, REASONING_EFFORT, and DEBUG_LOG_ENABLED from env", () => { + const resolved = resolveSettings( + { + env: { + THINKING_ENABLED: "true", + REASONING_EFFORT: "high", + DEBUG_LOG_ENABLED: "true", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, true); - assert.equal(resolved.reasoningEffort, "max"); + assert.equal(resolved.reasoningEffort, "high"); + assert.equal(resolved.debugLogEnabled, true); assert.equal(resolved.model, "default-model"); assert.equal(resolved.baseURL, "https://default.example.com"); }); -test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { +test("resolveSettings ignores removed legacy env.THINKING", () => { const resolved = resolveSettings( { env: { - MODEL: "deepseek-v4-flash" - } + THINKING: "enabled", + }, }, { model: "default-model", - baseURL: "https://default.example.com" + baseURL: "https://default.example.com", + }, + {} + ); + + assert.equal(resolved.thinkingEnabled, false); +}); + +test("resolveSettingsSources applies user, project, and DEEPCODE environment precedence", () => { + const resolved = resolveSettingsSources( + { + env: { + API_KEY: "user-key", + MODEL: "user-env-model", + THINKING_ENABLED: "false", + REASONING_EFFORT: "high", + DEBUG_LOG_ENABLED: "false", + WEBHOOK: "user-webhook", + }, + model: "user-top-model", + thinkingEnabled: true, + reasoningEffort: "max", + debugLogEnabled: true, + }, + { + env: { + API_KEY: "project-key", + MODEL: "project-env-model", + THINKING_ENABLED: "false", + DEBUG_LOG_ENABLED: "false", + }, + model: "project-top-model", + thinkingEnabled: true, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + { + DEEPCODE_MODEL: "system-model", + DEEPCODE_THINKING_ENABLED: "false", + DEEPCODE_REASONING_EFFORT: "high", + DEEPCODE_DEBUG_LOG_ENABLED: "true", + DEEPCODE_WEBHOOK: "system-webhook", } ); + assert.equal(resolved.model, "system-model"); + assert.equal(resolved.apiKey, "project-key"); + assert.equal(resolved.thinkingEnabled, false); + assert.equal(resolved.reasoningEffort, "high"); + assert.equal(resolved.debugLogEnabled, true); + assert.equal(resolved.env.WEBHOOK, "system-webhook"); +}); + +test("resolveSettingsSources merges permission settings", () => { + const resolved = resolveSettingsSources( + { + permissions: { + allow: ["read-in-cwd", "network"], + ask: ["write-out-cwd"], + defaultMode: "askAll", + }, + }, + { + permissions: { + allow: ["write-in-cwd", "read-in-cwd"], + deny: ["delete-out-cwd"], + defaultMode: "allowAll", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + + assert.deepEqual(resolved.permissions.allow, ["read-in-cwd", "network", "write-in-cwd"]); + assert.deepEqual(resolved.permissions.ask, ["write-out-cwd"]); + assert.deepEqual(resolved.permissions.deny, ["delete-out-cwd"]); + assert.equal(resolved.permissions.defaultMode, "allowAll"); +}); + +test("resolveSettingsSources merges MCP env with documented priority", () => { + const resolved = resolveSettingsSources( + { + env: { + MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "user-global", + }, + mcpServers: { + github: { + command: "node", + args: ["user-server.js"], + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: "user-local", + USER_ONLY: "1", + }, + }, + }, + }, + { + env: { + MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "project-global", + }, + mcpServers: { + github: { + command: "python", + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: "project-local", + PROJECT_ONLY: "1", + }, + }, + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + { + DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "system-global", + } + ); + + assert.equal(resolved.mcpServers?.github?.command, "python"); + assert.deepEqual(resolved.mcpServers?.github?.args, ["user-server.js"]); + assert.deepEqual(resolved.mcpServers?.github?.env, { + MCP_GITHUB_PERSONAL_ACCESS_TOKEN: "system-global", + GITHUB_PERSONAL_ACCESS_TOKEN: "system-global", + USER_ONLY: "1", + PROJECT_ONLY: "1", + }); +}); + +test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { + const resolved = resolveSettings( + { + env: { + MODEL: "deepseek-v4-flash", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.thinkingEnabled, true); }); @@ -71,8 +248,9 @@ test("resolveSettings applies thinking defaults to the fallback model", () => { {}, { model: "deepseek-v4-pro", - baseURL: "https://default.example.com" - } + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v4-pro"); @@ -83,13 +261,14 @@ test("resolveSettings keeps thinking mode off by default for other models", () = const resolved = resolveSettings( { env: { - MODEL: "deepseek-v3.2" - } + MODEL: "deepseek-v3.2", + }, }, { model: "default-model", - baseURL: "https://default.example.com" - } + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, false); @@ -99,14 +278,15 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults const resolved = resolveSettings( { env: { - MODEL: "deepseek-v4-pro" + MODEL: "deepseek-v4-pro", }, - thinkingEnabled: false + thinkingEnabled: false, }, { model: "default-model", - baseURL: "https://default.example.com" - } + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, false); @@ -115,61 +295,225 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults test("resolveSettings defaults invalid reasoning effort to max", () => { const resolved = resolveSettings( { - reasoningEffort: "medium" as never + reasoningEffort: "medium" as never, }, { model: "default-model", - baseURL: "https://default.example.com" - } + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV ); assert.equal(resolved.reasoningEffort, "max"); }); +test("applyModelConfigSelection writes model only when the effective model changes or already exists", () => { + const result = applyModelConfigSelection( + { + env: { + MODEL: "deepseek-v4-pro", + }, + thinkingEnabled: false, + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: false, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: true, + reasoningEffort: "high", + } + ); + + assert.equal(result.changed, true); + assert.equal(result.settings.model, undefined); + assert.equal(result.settings.thinkingEnabled, true); + assert.equal(result.settings.reasoningEffort, "high"); +}); + +test("applyModelConfigSelection persists a new selected model and thinking option", () => { + const result = applyModelConfigSelection( + { + env: { + MODEL: "deepseek-v4-pro", + BASE_URL: "https://api.deepseek.com", + API_KEY: "sk-test", + }, + thinkingEnabled: false, + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: false, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-flash", + thinkingEnabled: true, + reasoningEffort: "high", + } + ); + + assert.equal(result.changed, true); + assert.equal(result.settings.env?.MODEL, "deepseek-v4-pro"); + assert.equal(result.settings.model, "deepseek-v4-flash"); + assert.equal(result.settings.thinkingEnabled, true); + assert.equal(result.settings.reasoningEffort, "high"); +}); + +test("applyModelConfigSelection leaves settings untouched when the effective selection is unchanged", () => { + const result = applyModelConfigSelection( + { + env: { + MODEL: "deepseek-v4-pro", + }, + thinkingEnabled: true, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: true, + reasoningEffort: "max", + }, + { + model: "deepseek-v4-pro", + thinkingEnabled: true, + reasoningEffort: "max", + } + ); + + assert.equal(result.changed, false); + assert.equal(result.settings.model, undefined); +}); + test("formatDurationSeconds preserves sub-second precision and trims trailing zeros", () => { assert.equal(formatDurationSeconds(0), "0"); assert.equal(formatDurationSeconds(1250), "1"); assert.equal(formatDurationSeconds(4000), "4"); }); -test("buildNotifyEnv injects DURATION", () => { +test("buildNotifyEnv injects DURATION without context", () => { const env = buildNotifyEnv(2750, { HOME: "/tmp/home" }); assert.equal(env.HOME, "/tmp/home"); assert.equal(env.DURATION, "2"); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => { + const context: NotifyContext = { + status: "failed", + failReason: "API key not found", + body: "Hello, this is the last assistant message.", + title: "Fix login bug", + }; + const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context); + assert.equal(env.HOME, "/tmp/home"); + assert.equal(env.DURATION, "5"); + assert.equal(env.STATUS, "failed"); + assert.equal(env.FAIL_REASON, "API key not found"); + assert.equal(env.BODY, "Hello, this is the last assistant message."); + assert.equal(env.TITLE, "Fix login bug"); }); -test("launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", () => { - const calls: Array<{ - command: string; - args: string[]; - options: { cwd?: string | URL; env?: NodeJS.ProcessEnv }; - }> = []; +test("buildNotifyEnv omits optional context fields when not provided", () => { + const env = buildNotifyEnv( + 1000, + { + HOME: "/tmp/home", + STATUS: "stale-status", + FAIL_REASON: "stale-failure", + BODY: "stale-body", + TITLE: "stale-title", + }, + { status: "completed" } + ); + assert.equal(env.STATUS, "completed"); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); - const spawnProcess: NotifySpawn = (command, args, options) => { - calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); +test("buildNotifyEnv ignores empty strings in context", () => { + const env = buildNotifyEnv( + 1000, + { HOME: "/tmp/home" }, + { + status: "", + failReason: "", + body: "", + title: "", + } + ); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); - return { - once(event, listener) { - if (event === "error" && calls.length === 1) { - listener({ code: "EACCES" } as NodeJS.ErrnoException); - } - return this; - }, - unref() { - return undefined; - } - }; +test("buildNotifyEnv preserves special characters in body and title", () => { + const context: NotifyContext = { + body: 'Line 1\nLine 2\tindented "quoted"', + title: "Fix: login & signup (urgent)", }; + const env = buildNotifyEnv(1000, {}, context); + assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"'); + assert.equal(env.TITLE, "Fix: login & signup (urgent)"); +}); - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess); +test( + "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts", + { skip: process.platform === "win32" }, + () => { + const calls: Array<{ + command: string; + args: string[]; + options: { cwd?: string | URL; env?: NodeJS.ProcessEnv }; + }> = []; - assert.equal(calls.length, 2); - assert.equal(calls[0]?.command, "/tmp/notify.sh"); - assert.deepEqual(calls[0]?.args, []); - assert.equal(calls[0]?.options.cwd, "/tmp/project"); - assert.equal(calls[0]?.options.env?.DURATION, "2"); - assert.equal(calls[1]?.command, "/bin/sh"); - assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); - assert.equal(calls[1]?.options.cwd, "/tmp/project"); - assert.equal(calls[1]?.options.env?.DURATION, "2"); -}); + const spawnProcess: NotifySpawn = (command, args, options) => { + calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); + + return { + once(event, listener) { + if (event === "error" && calls.length === 1) { + listener({ code: "EACCES" } as NodeJS.ErrnoException); + } + return this; + }, + unref() { + return undefined; + }, + }; + }; + + const context: NotifyContext = { + status: "completed", + body: "Task finished successfully.", + title: "Fix login bug", + }; + + launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }, context); + + assert.equal(calls.length, 2); + assert.equal(calls[0]?.command, "/tmp/notify.sh"); + assert.deepEqual(calls[0]?.args, []); + assert.equal(calls[0]?.options.cwd, "/tmp/project"); + assert.equal(calls[0]?.options.env?.DURATION, "2"); + assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[0]?.options.env?.STATUS, "completed"); + assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined); + assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); + assert.equal(calls[1]?.command, "/bin/sh"); + assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); + assert.equal(calls[1]?.options.cwd, "/tmp/project"); + assert.equal(calls[1]?.options.env?.DURATION, "2"); + assert.equal(calls[1]?.options.env?.STATUS, "completed"); + assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug"); + } +); diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts new file mode 100644 index 0000000..50a71f4 --- /dev/null +++ b/src/tests/shell-utils.test.ts @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildDisableExtglobCommand, + getShellKind, + posixPathToWindowsPath, + resolveWindowsGitBashPath, + rewriteWindowsNullRedirect, + windowsPathToPosixPath, +} from "../common/shell-utils"; +import { isAbsoluteFilePath, normalizeFilePath } from "../common/state"; + +test("Windows paths convert to Git Bash POSIX paths", () => { + assert.equal(windowsPathToPosixPath("C:\\Users\\foo"), "/c/Users/foo"); + assert.equal(windowsPathToPosixPath("d:\\IdeaProjects\\guesswho-api"), "/d/IdeaProjects/guesswho-api"); + assert.equal(windowsPathToPosixPath("\\\\server\\share\\dir"), "//server/share/dir"); +}); + +test("Git Bash POSIX paths convert to native Windows paths", () => { + assert.equal(posixPathToWindowsPath("/c/Users/foo"), "C:\\Users\\foo"); + assert.equal(posixPathToWindowsPath("/cygdrive/d/IdeaProjects/guesswho-api"), "D:\\IdeaProjects\\guesswho-api"); + assert.equal(posixPathToWindowsPath("//server/share/dir"), "\\\\server\\share\\dir"); +}); + +test("Windows nul redirects are rewritten for POSIX bash", () => { + assert.equal(rewriteWindowsNullRedirect("cmd >nul"), "cmd >/dev/null"); + assert.equal(rewriteWindowsNullRedirect("cmd 2>NUL && next"), "cmd 2>/dev/null && next"); + assert.equal(rewriteWindowsNullRedirect("cmd &>nul\nnext"), "cmd &>/dev/null\nnext"); + assert.equal(rewriteWindowsNullRedirect("echo nullable"), "echo nullable"); +}); + +test("Shell kind detection supports Windows bash.exe paths", () => { + assert.equal(getShellKind("C:\\Program Files\\Git\\bin\\bash.exe"), "bash"); + assert.equal(getShellKind("/bin/zsh"), "zsh"); + assert.equal( + buildDisableExtglobCommand("C:\\Program Files\\Git\\bin\\bash.exe"), + "shopt -u extglob 2>/dev/null || true" + ); + assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true"); +}); + +test("Windows Git Bash detection prefers bash.exe from PATH", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => (executable === "bash" ? [bashPath] : []), + findGitExecPath: () => null, + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection derives bash.exe from git exec path", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: () => [], + findGitExecPath: () => "D:/Tools/Git/mingw64/libexec/git-core", + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection derives bash.exe from git.exe candidates", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => (executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : []), + findGitExecPath: () => null, + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection skips WSL System32 bash.exe in PATH results", () => { + // When WSL1 is enabled on older Windows 10, C:\Windows\System32\bash.exe + // appears in PATH. That launcher would execute commands inside the Linux + // distro instead of the Windows host, breaking all tool invocations. + // The PATH bash strategy should ignore it and fall through. + const system32Bash = "C:\\Windows\\System32\\bash.exe"; + const gitBash = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => + executable === "bash" ? [system32Bash] : executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : [], + findGitExecPath: () => null, + existsSync: (candidate) => candidate === gitBash, + }); + + assert.equal(resolved, gitBash); +}); + +test("File tool path normalization converts Git Bash drive paths on Windows", () => { + assert.equal( + normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), + "D:\\IdeaProjects\\guesswho-api\\API_DOCUMENTATION.md" + ); + assert.equal(normalizeFilePath("/cygdrive/c/Users/foo/file.txt", "win32"), "C:\\Users\\foo\\file.txt"); + assert.equal(normalizeFilePath("/dev/null", "win32"), "\\dev\\null"); +}); + +test("File tool absolute checks accept Git Bash drive paths but reject root-relative POSIX paths on Windows", () => { + assert.equal(isAbsoluteFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), true); + assert.equal(isAbsoluteFilePath("D:/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), true); + assert.equal(isAbsoluteFilePath("/dev/null", "win32"), false); + assert.equal(isAbsoluteFilePath("./API_DOCUMENTATION.md", "win32"), false); +}); diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 25e46e4..30d77ee 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -5,13 +5,13 @@ import { filterSlashCommands, findExactSlashCommand, formatSlashCommandDescription, - formatSlashCommandLabel -} from "../ui/slashCommands"; + formatSlashCommandLabel, +} from "../ui"; import type { SkillInfo } from "../session"; const skills: SkillInfo[] = [ { name: "skill-writer", path: "~/.agents/skills/skill-writer/SKILL.md", description: "Write a SKILL.md" }, - { name: "code-review", path: "~/.agents/skills/code-review/SKILL.md", description: "Review code" } + { name: "code-review", path: "~/.agents/skills/code-review/SKILL.md", description: "Review code" }, ]; test("buildSlashCommands prefixes skills before built-ins", () => { @@ -19,7 +19,18 @@ test("buildSlashCommands prefixes skills before built-ins", () => { assert.equal(items[0].kind, "skill"); assert.equal(items[0].name, "skill-writer"); const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name); - assert.deepEqual(builtinNames, ["skills", "new", "resume", "exit"]); + assert.deepEqual(builtinNames, [ + "skills", + "model", + "new", + "init", + "resume", + "continue", + "undo", + "mcp", + "raw", + "exit", + ]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -51,6 +62,28 @@ test("findExactSlashCommand returns built-in /new", () => { assert.equal(item?.kind, "new"); }); +test("findExactSlashCommand returns built-in /init", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/init"); + assert.ok(item); + assert.equal(item?.kind, "init"); + assert.equal(item?.description, "Initialize an AGENTS.md file with instructions for LLM"); +}); + +test("findExactSlashCommand returns built-in /continue", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/continue"); + assert.ok(item); + assert.equal(item?.kind, "continue"); +}); + +test("findExactSlashCommand returns built-in /undo", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/undo"); + assert.ok(item); + assert.equal(item?.kind, "undo"); +}); + test("findExactSlashCommand returns built-in /skills", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/skills"); @@ -58,6 +91,20 @@ test("findExactSlashCommand returns built-in /skills", () => { assert.equal(item?.kind, "skills"); }); +test("findExactSlashCommand returns built-in /model", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/model"); + assert.ok(item); + assert.equal(item?.kind, "model"); +}); + +test("findExactSlashCommand returns built-in /raw", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/raw"); + assert.ok(item); + assert.equal(item?.kind, "raw"); +}); + test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); @@ -73,7 +120,7 @@ test("formatSlashCommandDescription keeps descriptions on one line", () => { test("formatSlashCommandLabel marks loaded skills", () => { const items = buildSlashCommands([ { name: "loaded", path: "/skills/loaded/SKILL.md", description: "Loaded skill", isLoaded: true }, - { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" } + { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" }, ]); assert.equal(formatSlashCommandLabel(items[0]), "/loaded ✓"); diff --git a/src/tests/thinkingState.test.ts b/src/tests/thinkingState.test.ts index 1432d9f..8f2a0e3 100644 --- a/src/tests/thinkingState.test.ts +++ b/src/tests/thinkingState.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { findExpandedThinkingId } from "../ui/thinkingState"; +import { findExpandedThinkingId } from "../ui"; import type { SessionMessage } from "../session"; function buildMessage( @@ -19,7 +19,7 @@ function buildMessage( visible: true, createTime: "2026-04-28T00:00:00.000Z", updateTime: "2026-04-28T00:00:00.000Z", - meta: options.asThinking ? { asThinking: true } : undefined + meta: options.asThinking ? { asThinking: true } : undefined, }; } @@ -28,10 +28,7 @@ test("findExpandedThinkingId returns null on an empty list", () => { }); test("findExpandedThinkingId returns the only thinking id when there is no final reply", () => { - const messages = [ - buildMessage("user", "user"), - buildMessage("a-1", "assistant", { asThinking: true }) - ]; + const messages = [buildMessage("user", "user"), buildMessage("a-1", "assistant", { asThinking: true })]; assert.equal(findExpandedThinkingId(messages), "a-1"); }); @@ -39,16 +36,13 @@ test("findExpandedThinkingId always picks the latest thinking id", () => { const messages = [ buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("tool", "tool"), - buildMessage("a-2", "assistant", { asThinking: true }) + buildMessage("a-2", "assistant", { asThinking: true }), ]; assert.equal(findExpandedThinkingId(messages), "a-2"); }); test("findExpandedThinkingId returns null after a non-thinking assistant reply", () => { - const messages = [ - buildMessage("a-1", "assistant", { asThinking: true }), - buildMessage("a-final", "assistant") - ]; + const messages = [buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("a-final", "assistant")]; assert.equal(findExpandedThinkingId(messages), null); }); @@ -57,7 +51,7 @@ test("findExpandedThinkingId picks the thinking id that follows the last final r buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("a-final", "assistant"), buildMessage("a-2", "assistant", { asThinking: true }), - buildMessage("a-3", "assistant", { asThinking: true }) + buildMessage("a-3", "assistant", { asThinking: true }), ]; assert.equal(findExpandedThinkingId(messages), "a-3"); }); diff --git a/src/tests/tool-executor.test.ts b/src/tests/tool-executor.test.ts new file mode 100644 index 0000000..f36def2 --- /dev/null +++ b/src/tests/tool-executor.test.ts @@ -0,0 +1,41 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ToolExecutor } from "../tools/executor"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("ToolExecutor accepts title-case built-in tool aliases", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-tool-executor-")); + tempDirs.push(workspace); + const filePath = path.join(workspace, "sample.txt"); + fs.writeFileSync(filePath, "alpha\nbeta\n", "utf8"); + + const executor = new ToolExecutor(workspace); + const executions = await executor.executeToolCalls("alias-session", [ + { + id: "call-read", + type: "function", + function: { + name: "Read", + arguments: JSON.stringify({ file_path: filePath }), + }, + }, + ]); + + assert.equal(executions.length, 1); + assert.equal(executions[0]?.result.ok, true); + assert.equal(executions[0]?.result.name, "read"); + assert.match(executions[0]?.result.output ?? "", /alpha/); +}); diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index cc03a07..f66153c 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -3,9 +3,12 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import type { ToolExecutionContext } from "../tools/executor"; +import { setTimeout as delay } from "node:timers/promises"; +import type { ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor"; +import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; import { handleReadTool } from "../tools/read-handler"; +import { handleUpdatePlanTool } from "../tools/update-plan-handler"; import { handleWriteTool } from "../tools/write-handler"; const tempDirs: string[] = []; @@ -19,14 +22,117 @@ afterEach(() => { } }); +test("Bash streams stdout and stderr before command completion", async () => { + const workspace = createTempWorkspace(); + const chunks: string[] = []; + let completed = false; + + const resultPromise = handleBashTool( + { + command: "printf 'first\\n'; sleep 1; printf 'second\\n'; printf 'err\\n' >&2", + }, + createContext("bash-live-output", workspace, { + onProcessStdout: (_pid, chunk) => { + chunks.push(chunk); + }, + }) + ).finally(() => { + completed = true; + }); + + await waitFor(() => chunks.join("").includes("first"), 1500); + + assert.equal(completed, false); + + const result = await resultPromise; + const streamedOutput = chunks.join(""); + assert.equal(result.ok, true); + assert.match(streamedOutput, /first/); + assert.match(streamedOutput, /second/); + assert.match(streamedOutput, /err/); +}); + +test("Bash terminates commands that exceed the configured timeout", async () => { + const workspace = createTempWorkspace(); + const exitedPids: Array = []; + + const result = await handleBashTool( + { + command: "printf 'start\\n'; sleep 5; printf 'done\\n'", + }, + createContext("bash-timeout", workspace, { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessExit: (pid) => { + exitedPids.push(pid); + }, + }) + ); + + assert.equal(result.ok, false); + assert.equal(result.error, "Command timed out."); + assert.equal(result.metadata?.timedOut, true); + assert.equal(result.metadata?.timeoutMs, 100); + assert.doesNotMatch(result.output ?? "", /done/); + assert.equal(exitedPids.length, 1); +}); + +test("Bash timeout control can extend the active command deadline", async () => { + const workspace = createTempWorkspace(); + let timeoutControl: ProcessTimeoutControl | null = null; + + const result = await handleBashTool( + { + command: "sleep 0.2; printf 'done\\n'", + }, + createContext("bash-timeout-extend", workspace, { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessTimeoutControl: (_pid, control) => { + if (control) { + timeoutControl = control; + control.setTimeoutMs(1000); + } + }, + }) + ); + + assert.ok(timeoutControl); + assert.equal(result.ok, true); + assert.match(result.output ?? "", /done/); + assert.equal(result.metadata?.timedOut, false); + assert.equal(result.metadata?.timeoutMs, 1000); +}); + +test("UpdatePlan accepts a markdown task list string", async () => { + const workspace = createTempWorkspace(); + const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n"); + + const result = await handleUpdatePlanTool({ plan }, createContext("update-plan", workspace)); + + assert.equal(result.ok, true); + assert.equal(result.name, "UpdatePlan"); + assert.equal(result.output, "Plan updated."); + assert.equal(result.metadata?.plan, plan); +}); + +test("UpdatePlan rejects non-string plan payloads", async () => { + const workspace = createTempWorkspace(); + + const result = await handleUpdatePlanTool( + { plan: [{ step: "Inspect current behavior", status: "in_progress" }] }, + createContext("update-plan-invalid", workspace) + ); + + assert.equal(result.ok, false); + assert.equal(result.name, "UpdatePlan"); + assert.match(result.error ?? "", /InputValidationError/); +}); + test("Read returns snippet metadata and Edit can scope replacements by snippet_id", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "sample.txt"); - fs.writeFileSync( - filePath, - ["alpha", "target = 1", "omega", "beta", "target = 1", "done"].join("\n"), - "utf8" - ); + fs.writeFileSync(filePath, ["alpha", "target = 1", "omega", "beta", "target = 1", "done"].join("\n"), "utf8"); const sessionId = "snippet-scope"; const readResult = await handleReadTool( @@ -35,9 +141,7 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i ); assert.equal(readResult.ok, true); - const snippet = (readResult.metadata?.snippet ?? null) as - | { id: string; startLine: number; endLine: number } - | null; + const snippet = (readResult.metadata?.snippet ?? null) as { id: string; startLine: number; endLine: number } | null; assert.ok(snippet); assert.equal(snippet?.startLine, 4); assert.equal(snippet?.endLine, 5); @@ -46,7 +150,7 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i { snippet_id: snippet?.id, old_string: "target = 1", - new_string: "target = 2" + new_string: "target = 2", }, createContext(sessionId, workspace) ); @@ -75,16 +179,13 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn { file_path: filePath, old_string: "city", - new_string: "location" + new_string: "location", }, createContext(sessionId, workspace) ); assert.equal(editResult.ok, false); - assert.equal( - editResult.error, - "old_string is not unique; use snippet_id, replace_all, or provide more context." - ); + assert.equal(editResult.error, "old_string is not unique; use snippet_id, replace_all, or provide more context."); const candidates = (editResult.metadata?.candidates ?? []) as Array<{ snippet_id: string; start_line: number; @@ -97,6 +198,184 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn assert.match(candidates[0]?.preview ?? "", /city/); }); +test("Edit returns closest matches only above threshold with surrounding context", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "closest.ts"); + fs.writeFileSync( + filePath, + [ + "const before = true;", + "function computeSubtotal(value: number) {", + " return value;", + "}", + "const after = true;", + ].join("\n"), + "utf8" + ); + + const sessionId = "closest-match-context"; + await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + + const closeResult = await handleEditTool( + { + file_path: filePath, + old_string: "function computeTotal(value: number) {", + new_string: "function computeTotal(input: number) {", + }, + createContext(sessionId, workspace) + ); + + assert.equal(closeResult.ok, false); + assert.equal(closeResult.error, "old_string not found in file."); + const closestMatch = closeResult.metadata?.closest_match as + | { snippet_id?: string; start_line?: number; end_line?: number; similarity?: number; preview?: string } + | undefined; + assert.ok(closestMatch?.snippet_id); + assert.equal(closestMatch.start_line, 1); + assert.equal(closestMatch.end_line, 4); + assert.ok((closestMatch.similarity ?? 0) >= 0.8); + assert.match(closestMatch.preview ?? "", /const before = true/); + assert.match(closestMatch.preview ?? "", /return value/); + + const lowResult = await handleEditTool( + { + file_path: filePath, + old_string: 'query: string = Field(description="search query")', + new_string: "query: string", + }, + createContext(sessionId, workspace) + ); + + assert.equal(lowResult.ok, false); + assert.equal(lowResult.error, "old_string not found in file."); + assert.equal(lowResult.metadata?.closest_match, undefined); + + const partialRead = await handleReadTool( + { file_path: filePath, offset: 2, limit: 2 }, + createContext(sessionId, workspace) + ); + const snippet = (partialRead.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + const scopedCloseResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "function computeTotal(value: number) {", + new_string: "function computeTotal(input: number) {", + }, + createContext(sessionId, workspace) + ); + + assert.equal(scopedCloseResult.ok, false); + const scopedClosestMatch = scopedCloseResult.metadata?.closest_match as + | { start_line?: number; end_line?: number; preview?: string } + | undefined; + assert.equal(scopedClosestMatch?.start_line, 2); + assert.equal(scopedClosestMatch?.end_line, 3); + assert.doesNotMatch(scopedClosestMatch?.preview ?? "", /const before = true/); +}); + +test("Edit allows outdated snippet matches but reports outdated snippet when no match is found", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "snippet-outdated.txt"); + fs.writeFileSync(filePath, ["alpha = 1", "beta = 1", "gamma = 1"].join("\n"), "utf8"); + + const sessionId = "outdated-snippet-miss"; + const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + const firstEdit = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "alpha = 1", + new_string: "alpha = 2", + }, + createContext(sessionId, workspace) + ); + assert.equal(firstEdit.ok, true); + + const secondEdit = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "beta = 1", + new_string: "beta = 2", + }, + createContext(sessionId, workspace) + ); + assert.equal(secondEdit.ok, true); + assert.equal(fs.readFileSync(filePath, "utf8"), ["alpha = 2", "beta = 2", "gamma = 1"].join("\n")); + + const missingEdit = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "delta = 1", + new_string: "delta = 2", + }, + createContext(sessionId, workspace) + ); + + assert.equal(missingEdit.ok, false); + assert.equal( + missingEdit.error, + "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing." + ); + const outdatedScope = (missingEdit.metadata?.scope ?? {}) as { snippet_id?: string }; + assert.equal(outdatedScope.snippet_id, snippet.id); + + const freshRead = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const freshSnippet = (freshRead.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(freshSnippet); + + const freshMissingEdit = await handleEditTool( + { + snippet_id: freshSnippet.id, + old_string: "delta = 1", + new_string: "delta = 2", + }, + createContext(sessionId, workspace) + ); + + assert.equal(freshMissingEdit.ok, false); + assert.equal(freshMissingEdit.error, "old_string not found in file."); +}); + +test("Edit reports outdated snippet when a later Write changes the file and snippet matching fails", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "write-outdated.txt"); + fs.writeFileSync(filePath, ["alpha = 1", "beta = 1"].join("\n"), "utf8"); + + const sessionId = "write-outdated-snippet"; + const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + const writeResult = await handleWriteTool( + { + file_path: filePath, + content: ["alpha = 2", "gamma = 2"].join("\n"), + }, + createContext(sessionId, workspace) + ); + + assert.equal(writeResult.ok, true); + + const editResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "beta = 1", + new_string: "beta = 2", + }, + createContext(sessionId, workspace) + ); + + assert.equal(editResult.ok, false); + assert.equal( + editResult.error, + "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing." + ); +}); + test("replace_all requires expected_occurrences for broad short-fragment replacements", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "openapi.yaml"); @@ -111,16 +390,13 @@ test("replace_all requires expected_occurrences for broad short-fragment replace file_path: filePath, old_string: fragment, new_string: " schema:\n type: array", - replace_all: true + replace_all: true, }, createContext(sessionId, workspace) ); assert.equal(blockedResult.ok, false); - assert.match( - blockedResult.error ?? "", - /provide expected_occurrences to confirm this broader replacement/ - ); + assert.match(blockedResult.error ?? "", /provide expected_occurrences to confirm this broader replacement/); const allowedResult = await handleEditTool( { @@ -128,7 +404,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace old_string: fragment, new_string: " schema:\n type: array", replace_all: true, - expected_occurrences: 3 + expected_occurrences: 3, }, createContext(sessionId, workspace) ); @@ -139,7 +415,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace [ " schema:\n type: array", " schema:\n type: array", - " schema:\n type: array" + " schema:\n type: array", ].join("\n---\n") ); }); @@ -156,7 +432,7 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn { file_path: filePath, old_string: "params['city_json'] = f'\\\\\"{city}\\\\\"'", - new_string: "params['city_json'] = city" + new_string: "params['city_json'] = city", }, createContext(sessionId, workspace, { createOpenAIClient: () => ({ @@ -171,17 +447,17 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn "" + "" + "" + - "" - } - } - ] - }) - } - } + "", + }, + }, + ], + }), + }, + }, } as any, model: "test-model", - thinkingEnabled: false - }) + thinkingEnabled: false, + }), }) ); @@ -190,6 +466,80 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn assert.equal(fs.readFileSync(filePath, "utf8"), "params['city_json'] = city\n"); }); +test("Edit accepts a unique loose-escape match for over-escaped unicode sequences", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "keys.ts"); + fs.writeFileSync(filePath, 'const sequence = "\\u001B[13;2~";\n', "utf8"); + + const sessionId = "unicode-loose-escape"; + await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + + let llmCalls = 0; + const editResult = await handleEditTool( + { + file_path: filePath, + old_string: 'const sequence = "\\\\u001B[13;2~";', + new_string: 'const sequence = "\\\\u001B[13;130u";', + }, + createContext(sessionId, workspace, { + createOpenAIClient: () => ({ + client: { + chat: { + completions: { + create: async (request: { messages?: Array<{ content?: string }> }) => { + llmCalls += 1; + assert.match(String(request.messages?.[1]?.content ?? ""), /" + + '' + + '' + + "", + }, + }, + ], + }; + }, + }, + }, + } as any, + model: "test-model", + thinkingEnabled: false, + }), + }) + ); + + assert.equal(editResult.ok, true); + assert.equal(llmCalls, 1); + assert.equal(editResult.metadata?.matched_via, "llm_escape_correction"); + assert.equal(fs.readFileSync(filePath, "utf8"), 'const sequence = "\\u001B[13;130u";\n'); +}); + +test("Edit strips accidental read-result tabs after newlines when that creates a unique match", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "tabs.ts"); + fs.writeFileSync(filePath, ["function demo() {", " return 1;", "}"].join("\n") + "\n", "utf8"); + + const sessionId = "line-leading-tab-correction"; + await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + + const editResult = await handleEditTool( + { + file_path: filePath, + old_string: "function demo() {\n\t return 1;\n\t}", + new_string: "function demo() {\n\t return 2;\n\t}", + }, + createContext(sessionId, workspace) + ); + + assert.equal(editResult.ok, true); + assert.equal(editResult.metadata?.matched_via, "line_leading_tab_correction"); + assert.equal(fs.readFileSync(filePath, "utf8"), ["function demo() {", " return 2;", "}"].join("\n") + "\n"); +}); + test("Write repairs JSON object content for .json files", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "package.json"); @@ -199,8 +549,8 @@ test("Write repairs JSON object content for .json files", async () => { file_path: filePath, content: { name: "demo", - private: true - } as unknown as string + private: true, + } as unknown as string, }, createContext("write-json-object", workspace) ); @@ -212,10 +562,7 @@ test("Write repairs JSON object content for .json files", async () => { assert.equal(writeResult.metadata?.line_endings, "LF"); assert.equal(writeResult.metadata?.input_repaired, true); assert.match(String(writeResult.metadata?.diff_preview ?? ""), /\+\s*"name": "demo"|^\+\{/m); - assert.equal( - fs.readFileSync(filePath, "utf8"), - '{\n "name": "demo",\n "private": true\n}' - ); + assert.equal(fs.readFileSync(filePath, "utf8"), '{\n "name": "demo",\n "private": true\n}'); }); test("Write updates file state so a follow-up Edit can succeed without another Read", async () => { @@ -225,7 +572,7 @@ test("Write updates file state so a follow-up Edit can succeed without another R const writeResult = await handleWriteTool( { file_path: filePath, - content: "alpha\nbeta\n" + content: "alpha\nbeta\n", }, createContext("write-then-edit", workspace) ); @@ -238,7 +585,7 @@ test("Write updates file state so a follow-up Edit can succeed without another R { file_path: filePath, old_string: "beta", - new_string: "gamma" + new_string: "gamma", }, createContext("write-then-edit", workspace) ); @@ -261,7 +608,7 @@ test("Write requires a full read before overwriting an existing file", async () const blockedResult = await handleWriteTool( { file_path: filePath, - content: "rewritten" + content: "rewritten", }, createContext(sessionId, workspace) ); @@ -278,7 +625,7 @@ test("Write can overwrite an existing empty file without a prior read", async () const writeResult = await handleWriteTool( { file_path: filePath, - content: "initialized\n" + content: "initialized\n", }, createContext("write-empty-existing", workspace) ); @@ -306,7 +653,7 @@ test("Edit rejects stale reads after the file changes on disk", async () => { { file_path: filePath, old_string: "after", - new_string: "final" + new_string: "final", }, createContext(sessionId, workspace) ); @@ -322,7 +669,7 @@ test("Write preserves the exact trailing newline policy from the provided conten const writeResult = await handleWriteTool( { file_path: filePath, - content: "no trailing newline" + content: "no trailing newline", }, createContext("write-no-newline", workspace) ); @@ -344,7 +691,7 @@ test("Edit preserves CRLF line endings for existing files", async () => { { file_path: filePath, old_string: "beta", - new_string: "gamma" + new_string: "gamma", }, createContext(sessionId, workspace) ); @@ -365,10 +712,7 @@ test("Read returns an acknowledgement for images and attaches the image as a fol ) ); - const readResult = await handleReadTool( - { file_path: filePath }, - createContext("image-read", workspace) - ); + const readResult = await handleReadTool({ file_path: filePath }, createContext("image-read", workspace)); assert.equal(readResult.ok, true); assert.equal(readResult.output, "File loaded."); @@ -379,15 +723,11 @@ test("Read returns an acknowledgement for images and attaches the image as a fol const followUpMessage = readResult.followUpMessages?.[0]; assert.equal(followUpMessage?.role, "system"); assert.match(followUpMessage?.content ?? "", /pixel\.png/); - const contentParams = Array.isArray(followUpMessage?.contentParams) - ? followUpMessage.contentParams - : []; + const contentParams = Array.isArray(followUpMessage?.contentParams) ? followUpMessage.contentParams : []; assert.equal(contentParams.length, 1); assert.equal((contentParams[0] as { type?: unknown }).type, "image_url"); assert.match( - String( - ((contentParams[0] as { image_url?: { url?: unknown } }).image_url?.url ?? "") - ), + String((contentParams[0] as { image_url?: { url?: unknown } }).image_url?.url ?? ""), /^data:image\/png;base64,/ ); }); @@ -405,10 +745,10 @@ function createContext( type: "function", function: { name: "test", - arguments: "{}" - } + arguments: "{}", + }, }, - ...overrides + ...overrides, }; } @@ -417,3 +757,14 @@ function createTempWorkspace(): string { tempDirs.push(dir); return dir; } + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await delay(25); + } + assert.equal(predicate(), true); +} diff --git a/src/tests/updateCheck.test.ts b/src/tests/updateCheck.test.ts index 19341f6..ce77fe5 100644 --- a/src/tests/updateCheck.test.ts +++ b/src/tests/updateCheck.test.ts @@ -10,7 +10,7 @@ test("compareVersions orders semantic versions", () => { }); test("parseNpmViewVersion parses npm view JSON and plain output", () => { - assert.equal(parseNpmViewVersion("\"0.1.4\"\n"), "0.1.4"); + assert.equal(parseNpmViewVersion('"0.1.4"\n'), "0.1.4"); assert.equal(parseNpmViewVersion("0.1.5\n"), "0.1.5"); assert.equal(parseNpmViewVersion("\n"), null); }); diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index 2f69a10..417c6c4 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -20,41 +20,44 @@ afterEach(() => { } }); -test("WebSearch executes the configured script with the query as one argument", async () => { - const workspace = createTempWorkspace(); - const scriptPath = path.join(workspace, "web-search.sh"); - fs.writeFileSync( - scriptPath, - [ - "#!/bin/sh", - "printf 'query=%s\\n' \"$1\"", - "printf 'cwd=%s\\n' \"$PWD\"" - ].join("\n"), - "utf8" - ); - fs.chmodSync(scriptPath, 0o755); +test( + "WebSearch executes the configured script with the query as one argument", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempWorkspace(); + const scriptPath = path.join(workspace, "web-search.sh"); + fs.writeFileSync( + scriptPath, + [ + "#!/bin/sh", + "printf 'query=%s\\n' \"$1\"", + "printf 'cwd=%s\\n' \"$PWD\"", + "printf 'webhook=%s\\n' \"$WEBHOOK\"", + ].join("\n"), + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); - const starts: Array<{ id: string | number; command: string }> = []; - const exits: Array = []; - const result = await handleWebSearchTool( - { query: "latest node release" }, - createContext(workspace, { - webSearchTool: scriptPath, - onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id) - }) - ); - const realWorkspace = fs.realpathSync(workspace); + const starts: Array<{ id: string | number; command: string }> = []; + const exits: Array = []; + const result = await handleWebSearchTool( + { query: "latest node release" }, + createContext(workspace, { + webSearchTool: scriptPath, + env: { WEBHOOK: "configured" }, + onProcessStart: (id, command) => starts.push({ id, command }), + onProcessExit: (id) => exits.push(id), + }) + ); + const realWorkspace = fs.realpathSync(workspace); - assert.equal(result.ok, true); - assert.equal( - result.output, - `query=latest node release\ncwd=${realWorkspace}\n` - ); - assert.equal(starts.length, 1); - assert.match(starts[0].command, /^WebSearch: latest node release$/); - assert.deepEqual(exits, [starts[0].id]); -}); + assert.equal(result.ok, true); + assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\nwebhook=configured\n`); + assert.equal(starts.length, 1); + assert.match(starts[0].command, /^WebSearch: latest node release$/); + assert.deepEqual(exits, [starts[0].id]); + } +); test("WebSearch uses the default API when no script is configured", async () => { const workspace = createTempWorkspace(); @@ -72,15 +75,16 @@ test("WebSearch uses the default API when no script is configured", async () => choices: [ { message: { - content: "{\"dominant_language\":\"en\",\"reason\":\"Most Node.js release notes are published in English.\"}" - } - } - ] + content: + '{"dominant_language":"en","reason":"Most Node.js release notes are published in English."}', + }, + }, + ], }; } throw new Error(`Unexpected chat prompt: ${prompt}`); - } - } + }, + }, }, } as unknown as OpenAI; @@ -95,14 +99,14 @@ test("WebSearch uses the default API when no script is configured", async () => organic_results: [ { title: "Node.js Releases", - link: "https://nodejs.org/en/about/previous-releases" - } - ] + link: "https://nodejs.org/en/about/previous-releases", + }, + ], }, null, 2 - ) - }) + ), + }), } as Response; }) as typeof fetch; @@ -112,7 +116,7 @@ test("WebSearch uses the default API when no script is configured", async () => client: fakeClient, machineId: "machine-id-123", onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id) + onProcessExit: (id) => exits.push(id), }) ); @@ -131,15 +135,12 @@ test("WebSearch uses the default API when no script is configured", async () => test("WebSearch returns a configuration error when neither a script nor an LLM client is available", async () => { const workspace = createTempWorkspace(); - const result = await handleWebSearchTool( - { query: "latest node release" }, - createContext(workspace) - ); + const result = await handleWebSearchTool({ query: "latest node release" }, createContext(workspace)); assert.equal(result.ok, false); assert.equal( result.error, - "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json." + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json or ./.deepcode/settings.json." ); }); @@ -148,6 +149,7 @@ function createContext( options: { client?: OpenAI | null; webSearchTool?: string; + env?: Record; machineId?: string; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; @@ -161,18 +163,19 @@ function createContext( type: "function", function: { name: "WebSearch", - arguments: "{}" - } + arguments: "{}", + }, }, createOpenAIClient: () => ({ client: options.client ?? null, model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, - machineId: options.machineId + env: options.env, + machineId: options.machineId, }), onProcessStart: options.onProcessStart, - onProcessExit: options.onProcessExit + onProcessExit: options.onProcessExit, }; } diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index dd805e0..df7e109 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -1,23 +1,32 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildWelcomeTips, formatHomeRelativePath } from "../ui/WelcomeScreen"; +import * as os from "os"; +import * as path from "path"; +import { buildWelcomeTips, formatHomeRelativePath } from "../ui"; test("formatHomeRelativePath returns tilde for the home directory", () => { - assert.equal(formatHomeRelativePath("/Users/example", "/Users/example"), "~"); + const home = path.resolve("/Users/example"); + assert.equal(formatHomeRelativePath(home, home), "~"); }); test("formatHomeRelativePath shortens paths inside the home directory", () => { - assert.equal(formatHomeRelativePath("/Users/example/dev/project", "/Users/example"), "~/dev/project"); + const home = path.resolve("/Users/example"); + const result = formatHomeRelativePath(path.resolve("/Users/example/dev/project"), home); + assert.equal(result, `~${path.sep}dev${path.sep}project`); }); test("formatHomeRelativePath keeps paths outside the home directory absolute", () => { - assert.equal(formatHomeRelativePath("/tmp/project", "/Users/example"), "/tmp/project"); + const home = path.resolve("/Users/example"); + const other = path.resolve("/tmp/project"); + // The result should be the absolute path since it's outside home + const result = formatHomeRelativePath(other, home); + assert.equal(result, other); }); test("buildWelcomeTips includes built-in slash commands and loaded skills", () => { const tips = buildWelcomeTips([ { name: "loaded", path: "/skills/loaded/SKILL.md", description: "Loaded skill", isLoaded: true }, - { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" } + { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" }, ]); const labels = tips.map((tip) => tip.label); diff --git a/src/tools/ask-user-question-handler.ts b/src/tools/ask-user-question-handler.ts index 4654066..8608508 100644 --- a/src/tools/ask-user-question-handler.ts +++ b/src/tools/ask-user-question-handler.ts @@ -25,13 +25,13 @@ export async function handleAskUserQuestionTool( return { ok: false, name: "AskUserQuestion", - error: questions.error + error: questions.error, }; } const metadata: AskUserQuestionMetadata = { kind: "ask_user_question", - questions: questions.value + questions: questions.value, }; return { @@ -39,17 +39,15 @@ export async function handleAskUserQuestionTool( name: "AskUserQuestion", output: buildQuestionSummary(questions.value), metadata, - awaitUserResponse: true + awaitUserResponse: true, }; } -function parseQuestions( - raw: unknown -): { ok: true; value: AskUserQuestionItem[] } | { ok: false; error: string } { +function parseQuestions(raw: unknown): { ok: true; value: AskUserQuestionItem[] } | { ok: false; error: string } { if (!Array.isArray(raw) || raw.length === 0) { return { ok: false, - error: "\"questions\" must be a non-empty array." + error: '"questions" must be a non-empty array.', }; } @@ -59,17 +57,18 @@ function parseQuestions( if (!item || typeof item !== "object" || Array.isArray(item)) { return { ok: false, - error: `Question at index ${index} must be an object.` + error: `Question at index ${index} must be an object.`, }; } - const question = typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; + const question = + typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; if (!question) { return { ok: false, - error: `Question at index ${index} is missing a non-empty "question" string.` + error: `Question at index ${index} is missing a non-empty "question" string.`, }; } @@ -77,7 +76,7 @@ function parseQuestions( if (!Array.isArray(rawOptions) || rawOptions.length === 0) { return { ok: false, - error: `Question at index ${index} must include a non-empty "options" array.` + error: `Question at index ${index} must include a non-empty "options" array.`, }; } @@ -87,44 +86,45 @@ function parseQuestions( if (!option || typeof option !== "object" || Array.isArray(option)) { return { ok: false, - error: `Option ${optionIndex} for question ${index} must be an object.` + error: `Option ${optionIndex} for question ${index} must be an object.`, }; } - const label = typeof (option as { label?: unknown }).label === "string" - ? (option as { label: string }).label.trim() - : ""; + const label = + typeof (option as { label?: unknown }).label === "string" ? (option as { label: string }).label.trim() : ""; if (!label) { return { ok: false, - error: `Option ${optionIndex} for question ${index} is missing a non-empty "label" string.` + error: `Option ${optionIndex} for question ${index} is missing a non-empty "label" string.`, }; } - const description = typeof (option as { description?: unknown }).description === "string" - ? (option as { description: string }).description.trim() - : undefined; + const description = + typeof (option as { description?: unknown }).description === "string" + ? (option as { description: string }).description.trim() + : undefined; options.push({ label, - description: description || undefined + description: description || undefined, }); } - const multiSelect = typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" - ? (item as { multiSelect: boolean }).multiSelect - : undefined; + const multiSelect = + typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" + ? (item as { multiSelect: boolean }).multiSelect + : undefined; questions.push({ question, multiSelect, - options + options, }); } return { ok: true, - value: questions + value: questions, }; } diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 49017e6..4272271 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -1,5 +1,15 @@ import { spawn } from "child_process"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { DEFAULT_BASH_TIMEOUT_MS, clampBashTimeoutMs } from "../common/bash-timeout"; +import { killProcessTree } from "../common/process-tree"; +import type { ProcessTimeoutControl, ProcessTimeoutInfo, ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { + buildDisableExtglobCommand, + buildShellEnv, + buildShellInitCommand, + resolveShellPath, + rewriteWindowsNullRedirect, + toNativeCwd, +} from "../common/shell-utils"; const MAX_OUTPUT_CHARS = 30000; const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; @@ -12,6 +22,11 @@ type ToolCommandResult = { exitCode: number | null; signal: string | null; truncated: boolean; + shellPath?: string; + startCwd?: string; + timedOut?: boolean; + timeoutMs?: number; + deadlineAt?: string; }; export async function handleBashTool( @@ -23,7 +38,7 @@ export async function handleBashTool( return { ok: false, name: "bash", - error: "Missing required \"command\" string." + error: 'Missing required "command" string.', }; } @@ -36,17 +51,18 @@ export async function handleBashTool( execution.stderr, marker, execution.exitCode, - execution.signal + execution.signal, + shellPath, + startCwd, + execution.timedOut, + execution.timeoutMs, + execution.deadlineAtMs ); updateSessionCwd(context.sessionId, startCwd, result.cwd); if (execution.error || result.exitCode !== 0 || result.signal !== null) { - const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error); - return formatResult( - { ...result, ok: false }, - "bash", - errorMessage - ); + const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error, execution.timedOut); + return formatResult({ ...result, ok: false }, "bash", errorMessage); } return formatResult(result, "bash"); @@ -69,12 +85,17 @@ function buildShellCommand(command: string): { const shellPath = resolveShellPath(); const marker = buildMarker(); const initCommand = buildShellInitCommand(shellPath); + const disableExtglobCommand = buildDisableExtglobCommand(shellPath); + const normalizedCommand = rewriteWindowsNullRedirect(command); const wrappedParts = []; if (initCommand) { wrappedParts.push(initCommand); } + if (disableExtglobCommand) { + wrappedParts.push(disableExtglobCommand); + } wrappedParts.push( - command, + normalizedCommand, "__DEEPCODE_STATUS__=$?", `printf '%s%s\\n' "${marker}" "$PWD"`, "exit $__DEEPCODE_STATUS__" @@ -89,18 +110,82 @@ async function executeShellCommand( cwd: string, command: string, context: ToolExecutionContext -): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> { +): Promise<{ + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + error?: string; + timedOut: boolean; + timeoutMs: number; + deadlineAtMs: number; +}> { return new Promise((resolve) => { const detached = process.platform !== "win32"; + const configuredEnv = context.createOpenAIClient?.().env ?? {}; + const minTimeoutMs = context.bashMinTimeoutMs; + const initialTimeoutMs = clampBashTimeoutMs(context.bashTimeoutMs ?? DEFAULT_BASH_TIMEOUT_MS, minTimeoutMs); + const startedAtMs = Date.now(); + let timeoutMs = initialTimeoutMs; + let deadlineAtMs = startedAtMs + timeoutMs; + let timedOut = false; + let settled = false; + let timeoutTimer: ReturnType | null = null; const child = spawn(shellPath, shellArgs, { cwd, - env: process.env, + env: buildShellEnv(shellPath, configuredEnv), detached, - stdio: ["ignore", "pipe", "pipe"] + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; + + const getTimeoutInfo = (): ProcessTimeoutInfo => ({ + timeoutMs, + startedAtMs, + deadlineAtMs, + timedOut, + }); + const stopTimeoutTimer = () => { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + }; + const triggerTimeout = () => { + if (settled || timedOut || typeof pid !== "number") { + return; + } + timedOut = true; + stopTimeoutTimer(); + killProcessTree(pid, "SIGKILL"); + }; + const scheduleTimeout = () => { + stopTimeoutTimer(); + if (settled) { + return; + } + const remainingMs = Math.max(0, deadlineAtMs - Date.now()); + timeoutTimer = setTimeout(triggerTimeout, remainingMs); + }; + const timeoutControl: ProcessTimeoutControl = { + getInfo: getTimeoutInfo, + setTimeoutMs: (nextTimeoutMs) => { + timeoutMs = clampBashTimeoutMs(nextTimeoutMs, minTimeoutMs); + deadlineAtMs = startedAtMs + timeoutMs; + if (deadlineAtMs <= Date.now()) { + triggerTimeout(); + } else { + scheduleTimeout(); + } + return getTimeoutInfo(); + }, + }; + if (typeof pid === "number") { context.onProcessStart?.(pid, command); + context.onProcessTimeoutControl?.(pid, timeoutControl); + scheduleTimeout(); } let stdout = ""; @@ -109,9 +194,13 @@ async function executeShellCommand( child.stdout?.on("data", (chunk: string | Buffer) => { stdout = appendChunk(stdout, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); }); child.stderr?.on("data", (chunk: string | Buffer) => { stderr = appendChunk(stderr, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); }); child.on("error", (spawnError) => { @@ -119,7 +208,10 @@ async function executeShellCommand( }); child.on("close", (code, signal) => { + settled = true; + stopTimeoutTimer(); if (typeof pid === "number") { + context.onProcessTimeoutControl?.(pid, null); context.onProcessExit?.(pid); } resolve({ @@ -127,7 +219,10 @@ async function executeShellCommand( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error + error, + timedOut, + timeoutMs, + deadlineAtMs, }); }); }); @@ -142,30 +237,6 @@ function appendChunk(existing: string, chunk: string | Buffer): string { return `${existing}${text.slice(0, remaining)}`; } -function resolveShellPath(): string { - const envShell = process.env.SHELL; - if (envShell && /\/(bash|zsh)$/.test(envShell)) { - return envShell; - } - return "/bin/bash"; -} - -function buildShellInitCommand(shellPath: string): string | null { - if (/\/zsh$/.test(shellPath)) { - return [ - 'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', - 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi' - ].join("; "); - } - if (/\/bash$/.test(shellPath)) { - return [ - 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"', - 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi' - ].join("; "); - } - return null; -} - function buildMarker(): string { const token = Math.random().toString(36).slice(2); return `__DEEPCODE_PWD__${token}__`; @@ -176,7 +247,12 @@ function buildToolCommandResult( stderr: string, marker: string, exitCode: number | null, - signal: string | null + signal: string | null, + shellPath: string, + startCwd: string, + timedOut: boolean = false, + timeoutMs?: number, + deadlineAtMs?: number ): ToolCommandResult { const { output: cleanedStdout, cwd } = stripMarker(stdout, marker); const combined = joinOutput(cleanedStdout, stderr); @@ -187,7 +263,12 @@ function buildToolCommandResult( cwd, exitCode, signal, - truncated + truncated, + shellPath, + startCwd, + timedOut, + timeoutMs, + deadlineAt: typeof deadlineAtMs === "number" ? new Date(deadlineAtMs).toISOString() : undefined, }; } @@ -210,7 +291,8 @@ function stripMarker(stdout: string, marker: string): { output: string; cwd: str } const markerLine = lines[markerIndex]; - const cwd = markerLine.slice(marker.length).trim() || null; + const shellCwd = markerLine.slice(marker.length).trim(); + const cwd = shellCwd ? toNativeCwd(shellCwd) : null; lines.splice(markerIndex, 1); return { output: lines.join("\n"), cwd }; } @@ -231,27 +313,40 @@ function truncateOutput(output: string): { text: string; truncated: boolean } { return { text: output.slice(0, MAX_OUTPUT_CHARS), truncated: true }; } -function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string): string { +function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string, timedOut = false): string { + if (error) { + return error; + } + if (timedOut) { + return "Command timed out."; + } if (signal) { return `Command terminated by signal ${signal}.`; } if (exitCode !== null) { return `Command failed with exit code ${exitCode}.`; } - return error || "Command failed."; + return "Command failed."; } -function formatResult( - result: ToolCommandResult, - name: string, - errorMessage?: string -): ToolExecutionResult { +function formatResult(result: ToolCommandResult, name: string, errorMessage?: string): ToolExecutionResult { const metadata: Record = { exitCode: result.exitCode, signal: result.signal, cwd: result.cwd, - truncated: result.truncated + truncated: result.truncated, + shellPath: result.shellPath, + startCwd: result.startCwd, }; + if (typeof result.timedOut === "boolean") { + metadata.timedOut = result.timedOut; + } + if (typeof result.timeoutMs === "number") { + metadata.timeoutMs = result.timeoutMs; + } + if (result.deadlineAt) { + metadata.deadlineAt = result.deadlineAt; + } const outputValue = result.output ? result.output : undefined; @@ -260,6 +355,6 @@ function formatResult( name, output: outputValue, error: errorMessage, - metadata + metadata, }; } diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 73ffe15..454a673 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -1,28 +1,32 @@ import * as fs from "fs"; -import * as path from "path"; import { z } from "zod"; -import { buildThinkingRequestOptions } from "../openai-thinking"; +import { buildThinkingRequestOptions } from "../common/openai-thinking"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; import { buildDiffPreview, hasFileChangedSinceState, readTextFileWithMetadata, - writeTextFile -} from "./file-utils"; -import { executeValidatedTool, semanticBoolean } from "./runtime"; + writeTextFile, +} from "../common/file-utils"; +import { executeValidatedTool, semanticBoolean } from "../common/runtime"; import { createSnippet, getFileState, getSnippet, + hasSnippetOutdatedFileVersion, + isAbsoluteFilePath, isFullFileView, normalizeFilePath, - recordFileState -} from "./state"; + recordFileState, +} from "../common/state"; const MAX_CANDIDATE_COUNT = 5; const REPLACE_ALL_MATCH_THRESHOLD = 5; const SHORT_REPLACE_ALL_LENGTH = 40; -const MIN_FUZZY_SCORE = 0.45; +const MIN_FUZZY_SCORE = 0.8; +const CLOSEST_MATCH_CONTEXT_LINES = 2; +const OUTDATED_SNIPPET_NOT_FOUND_ERROR = + "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing."; type LineIndex = { lines: string[]; @@ -77,7 +81,7 @@ const editSchema = z.strictObject({ return Number(value); } return value; - }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()) + }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()), }); export async function handleEditTool( @@ -98,7 +102,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "Missing required \"file_path\" string or \"snippet_id\" string." + error: 'Missing required "file_path" string or "snippet_id" string.', }; } @@ -107,11 +111,11 @@ export async function handleEditTool( } filePath = normalizeFilePath(filePath); - if (!path.isAbsolute(filePath)) { + if (!isAbsoluteFilePath(filePath)) { return { ok: false, name: "edit", - error: "file_path must be an absolute path." + error: "file_path must be an absolute path.", }; } @@ -119,7 +123,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `Unknown snippet_id: ${snippetId}` + error: `Unknown snippet_id: ${snippetId}`, }; } @@ -127,7 +131,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "snippet_id does not belong to the provided file_path." + error: "snippet_id does not belong to the provided file_path.", }; } @@ -135,7 +139,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "old_string must not be empty." + error: "old_string must not be empty.", }; } @@ -143,7 +147,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "new_string must differ from old_string." + error: "new_string must differ from old_string.", }; } @@ -151,7 +155,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `File not found: ${filePath}` + error: `File not found: ${filePath}`, }; } @@ -163,7 +167,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `Failed to stat file: ${message}` + error: `Failed to stat file: ${message}`, }; } @@ -171,7 +175,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "file_path points to a directory." + error: "file_path points to a directory.", }; } @@ -180,7 +184,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "Must read file before editing." + error: "Must read file before editing.", }; } @@ -188,7 +192,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "File was only partially read. Use snippet_id or read the full file before editing." + error: "File was only partially read. Use snippet_id or read the full file before editing.", }; } @@ -196,7 +200,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "File has been modified since read. Read it again before editing." + error: "File has been modified since read. Read it again before editing.", }; } @@ -209,10 +213,23 @@ export async function handleEditTool( const lineIndex = buildLineIndex(raw); const scope = buildSearchScope(filePath, raw, lineIndex, snippet ?? null); let matches = findOccurrences(raw, oldString, scope); - let matchedVia: "exact" | "loose_escape" | "llm_escape_correction" = "exact"; + let matchedVia: "exact" | "line_leading_tab_correction" | "loose_escape" | "llm_escape_correction" = "exact"; let replacementOldString = oldString; let replacementNewString = newString; + if (matches.length === 0) { + const tabStrippedOldString = stripReadResultLineTabs(oldString); + if (tabStrippedOldString !== oldString) { + const tabStrippedMatches = findOccurrences(raw, tabStrippedOldString, scope); + if (tabStrippedMatches.length === 1) { + matches = tabStrippedMatches; + matchedVia = "line_leading_tab_correction"; + replacementOldString = tabStrippedOldString; + replacementNewString = stripReadResultLineTabs(newString); + } + } + } + if (matches.length === 0) { const looseEscapeMatches = findLooseEscapeMatches(raw, oldString, scope); if (looseEscapeMatches.length === 1 && looseEscapeMatches[0]?.score === 1) { @@ -242,6 +259,17 @@ export async function handleEditTool( } if (matches.length === 0) { + if (snippet && hasSnippetOutdatedFileVersion(context.sessionId, snippet)) { + return { + ok: false, + name: "edit", + error: OUTDATED_SNIPPET_NOT_FOUND_ERROR, + metadata: { + scope: formatScopeMetadata(scope), + }, + }; + } + const closestMatch = findClosestMatch(raw, oldString, scope, lineIndex); return { ok: false, @@ -250,15 +278,11 @@ export async function handleEditTool( metadata: closestMatch ? { scope: formatScopeMetadata(scope), - closest_match: buildClosestMatchMetadata( - context.sessionId, - filePath, - closestMatch - ) + closest_match: buildClosestMatchMetadata(context.sessionId, filePath, closestMatch), } : { - scope: formatScopeMetadata(scope) - } + scope: formatScopeMetadata(scope), + }, }; } @@ -270,8 +294,8 @@ export async function handleEditTool( metadata: { match_count: matches.length, scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches) - } + candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), + }, }; } @@ -280,7 +304,7 @@ export async function handleEditTool( replaceAll, matchCount: matches.length, oldString: replacementOldString, - expectedOccurrences + expectedOccurrences, }); if (replaceAllGuardError) { return { @@ -290,28 +314,28 @@ export async function handleEditTool( metadata: { match_count: matches.length, scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches) - } + candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), + }, }; } - const updated = applyReplacement( - raw, - replacementOldString, - replacementNewString, - matches, - replaceAll - ); + const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll); const diffPreview = buildDiffPreview(filePath, raw, updated); + context.onBeforeFileMutation?.(filePath); writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings); + context.onAfterFileMutation?.(filePath); const freshMetadata = readTextFileWithMetadata(filePath); - recordFileState(context.sessionId, { - filePath, - content: freshMetadata.content, - timestamp: freshMetadata.timestamp, - encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings - }); + recordFileState( + context.sessionId, + { + filePath, + content: freshMetadata.content, + timestamp: freshMetadata.timestamp, + encoding: freshMetadata.encoding, + lineEndings: freshMetadata.lineEndings, + }, + { incrementVersion: true } + ); const replacedCount = replaceAll ? matches.length : 1; return { ok: true, @@ -326,15 +350,15 @@ export async function handleEditTool( encoding: freshMetadata.encoding, line_endings: freshMetadata.lineEndings, diff_preview: diffPreview, - scope: formatScopeMetadata(scope) - } + scope: formatScopeMetadata(scope), + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "edit", - error: message + error: message, }; } }, @@ -348,7 +372,7 @@ export async function handleEditTool( nextInput.snippet_id = nextInput.snippet_id.trim(); } return { ok: true, input: nextInput }; - } + }, } ); } @@ -387,7 +411,7 @@ function buildSearchScope( endOffset: raw.length, startLine: 1, endLine: lineIndex.lines.length, - snippetId: null + snippetId: null, }; } @@ -399,7 +423,7 @@ function buildSearchScope( endOffset: lineIndex.lineStarts[safeEndLine + 1], startLine: safeStartLine, endLine: safeEndLine, - snippetId: snippet.id + snippetId: snippet.id, }; } @@ -427,7 +451,7 @@ function findOccurrences(raw: string, needle: string, scope: SearchScope): Match startOffset, endOffset, startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)) + endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), }); searchIndex = found + needle.length; } @@ -462,7 +486,7 @@ function findLooseEscapeMatches(raw: string, needle: string, scope: SearchScope) startOffset, endOffset, startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)) + endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), }); } @@ -496,10 +520,7 @@ function validateReplaceAllGuard(input: { } if (input.expectedOccurrences !== null && input.expectedOccurrences !== input.matchCount) { - return ( - `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + - `but found ${input.matchCount}.` - ); + return `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + `but found ${input.matchCount}.`; } const isShortFragment = input.oldString.trim().length < SHORT_REPLACE_ALL_LENGTH; @@ -539,6 +560,10 @@ function applyReplacement( return result; } +function stripReadResultLineTabs(value: string): string { + return value.replaceAll("\n\t", "\n"); +} + function buildCandidateMetadata( sessionId: string, filePath: string, @@ -552,7 +577,7 @@ function buildCandidateMetadata( snippet_id: snippet?.id ?? null, start_line: match.startLine, end_line: match.endLine, - preview + preview, }; }); } @@ -562,17 +587,8 @@ function buildClosestMatchMetadata( filePath: string, closestMatch: ClosestMatch ): Record { - const preview = formatWithLineNumbers( - closestMatch.text.split(/\r?\n/), - closestMatch.startLine - ); - const snippet = createSnippet( - sessionId, - filePath, - closestMatch.startLine, - closestMatch.endLine, - preview - ); + const preview = formatWithLineNumbers(closestMatch.text.split(/\r?\n/), closestMatch.startLine); + const snippet = createSnippet(sessionId, filePath, closestMatch.startLine, closestMatch.endLine, preview); return { snippet_id: snippet?.id ?? null, @@ -580,7 +596,7 @@ function buildClosestMatchMetadata( end_line: closestMatch.endLine, similarity: Number(closestMatch.score.toFixed(3)), strategy: closestMatch.strategy, - preview + preview, }; } @@ -589,7 +605,7 @@ function formatScopeMetadata(scope: SearchScope): Record { file_path: scope.filePath, start_line: scope.startLine, end_line: scope.endLine, - snippet_id: scope.snippetId + snippet_id: scope.snippetId, }; } @@ -600,9 +616,7 @@ function buildPreview(raw: string, startLine: number, endLine: number): string { } function formatWithLineNumbers(lines: string[], startLine: number): string { - return lines - .map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`) - .join("\n"); + return lines.map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`).join("\n"); } function findClosestMatch( @@ -620,15 +634,15 @@ function findClosestMatch( startLine: match.startLine, endLine: match.endLine, score: match.score, - strategy: "loose_escape" + strategy: "loose_escape", }; if (!bestLooseMatch || candidate.score > bestLooseMatch.score) { bestLooseMatch = candidate; } } - if (bestLooseMatch) { - return bestLooseMatch; + if (bestLooseMatch && bestLooseMatch.score >= MIN_FUZZY_SCORE) { + return expandClosestMatch(raw, lineIndex, scope, bestLooseMatch); } } @@ -655,7 +669,7 @@ function findClosestMatch( startLine, endLine, score, - strategy: "fuzzy_window" + strategy: "fuzzy_window", }; if (!bestMatch || candidate.score > bestMatch.score) { @@ -664,7 +678,23 @@ function findClosestMatch( } } - return bestMatch; + return bestMatch ? expandClosestMatch(raw, lineIndex, scope, bestMatch) : null; +} + +function expandClosestMatch( + raw: string, + lineIndex: LineIndex, + scope: SearchScope, + closestMatch: ClosestMatch +): ClosestMatch { + const startLine = clamp(closestMatch.startLine - CLOSEST_MATCH_CONTEXT_LINES, scope.startLine, scope.endLine); + const endLine = clamp(closestMatch.endLine + CLOSEST_MATCH_CONTEXT_LINES, startLine, scope.endLine); + return { + ...closestMatch, + text: sliceLines(raw, lineIndex, startLine, endLine), + startLine, + endLine, + }; } function buildLooseEscapeRegex(source: string): RegExp | null { @@ -680,7 +710,7 @@ function buildLooseEscapeRegex(source: string): RegExp | null { slashEnd += 1; } - if (slashEnd < source.length && isEscapeSensitiveChar(source[slashEnd])) { + if (slashEnd < source.length) { pattern += "\\\\*"; pattern += escapeRegExp(source[slashEnd]); index = slashEnd; @@ -724,7 +754,7 @@ async function correctEscapedStringsWithLLM( content: "You correct file-edit strings when the only problem is escaping. " + "Return XML only using ....... " + - "Do not change semantics; only fix quoting or escaping so corrected_old_string matches the snippet exactly." + "Do not change semantics; only fix quoting or escaping so corrected_old_string matches the snippet exactly.", }, { role: "user", @@ -740,10 +770,10 @@ async function correctEscapedStringsWithLLM( " \n" + " \n" + " \n" + - "" - } + "", + }, ], - ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort) + ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort), }); const content = response.choices?.[0]?.message?.content ?? ""; @@ -786,23 +816,16 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null const correctedOldString = oldMatch?.[1] ?? oldMatch?.[2]; const correctedNewString = newMatch?.[1] ?? newMatch?.[2]; - if ( - typeof correctedOldString === "string" && - typeof correctedNewString === "string" - ) { + if (typeof correctedOldString === "string" && typeof correctedNewString === "string") { return { oldString: correctedOldString, - newString: correctedNewString + newString: correctedNewString, }; } return null; } -function isEscapeSensitiveChar(value: string): boolean { - return value === "\"" || value === "'" || value === "`" || value === "\\"; -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/tools/executor.ts b/src/tools/executor.ts index a7705ef..220fc89 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -4,8 +4,10 @@ import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; +import { handleUpdatePlanTool } from "./update-plan-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; +import type { McpManager } from "../mcp/mcp-manager"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -13,8 +15,10 @@ export type CreateOpenAIClient = () => { baseURL?: string; thinkingEnabled: boolean; reasoningEffort?: ReasoningEffort; + debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + env?: Record; machineId?: string; }; @@ -34,14 +38,36 @@ export type ToolExecutionContext = { createOpenAIClient?: CreateOpenAIClient; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; + onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + onBeforeFileMutation?: (filePath: string) => void; + onAfterFileMutation?: (filePath: string) => void; + bashTimeoutMs?: number; + bashMinTimeoutMs?: number; }; export type ToolExecutionHooks = { onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; + onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + onBeforeFileMutation?: (filePath: string) => void; + onAfterFileMutation?: (filePath: string) => void; shouldStop?: () => boolean; }; +export type ProcessTimeoutInfo = { + timeoutMs: number; + startedAtMs: number; + deadlineAtMs: number; + timedOut: boolean; +}; + +export type ProcessTimeoutControl = { + getInfo: () => ProcessTimeoutInfo; + setTimeoutMs: (timeoutMs: number) => ProcessTimeoutInfo; +}; + export type ToolExecutionResult = { ok: boolean; name: string; @@ -63,6 +89,13 @@ export type ToolHandler = ( context: ToolExecutionContext ) => Promise; +const BUILT_IN_TOOL_NAME_ALIASES = new Map([ + ["Bash", "bash"], + ["Read", "read"], + ["Write", "write"], + ["Edit", "edit"], +]); + export type ToolCallExecution = { toolCallId: string; content: string; @@ -72,11 +105,13 @@ export type ToolCallExecution = { export class ToolExecutor { private readonly projectRoot: string; private readonly createOpenAIClient?: CreateOpenAIClient; + private readonly mcpManager?: McpManager; private readonly toolHandlers = new Map(); - constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient) { + constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient, mcpManager?: McpManager) { this.projectRoot = projectRoot; this.createOpenAIClient = createOpenAIClient; + this.mcpManager = mcpManager; this.registerToolHandlers(); } @@ -98,7 +133,7 @@ export class ToolExecutor { executions.push({ toolCallId: toolCall.id, content: this.formatToolResult(result), - result + result, }); if (hooks?.shouldStop?.()) { break; @@ -113,6 +148,7 @@ export class ToolExecutor { this.toolHandlers.set("write", handleWriteTool); this.toolHandlers.set("edit", handleEditTool); this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); + this.toolHandlers.set("UpdatePlan", handleUpdatePlanTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } @@ -140,16 +176,15 @@ export class ToolExecutor { return null; } - const rawArguments = - typeof functionRecord.arguments === "string" ? functionRecord.arguments : ""; + const rawArguments = typeof functionRecord.arguments === "string" ? functionRecord.arguments : ""; return { id: record.id, type: "function", function: { name: functionRecord.name, - arguments: rawArguments - } + arguments: rawArguments, + }, }; } @@ -159,12 +194,19 @@ export class ToolExecutor { hooks?: ToolExecutionHooks ): Promise { const toolName = toolCall.function.name; - const handler = this.toolHandlers.get(toolName); + const handlerName = BUILT_IN_TOOL_NAME_ALIASES.get(toolName) ?? toolName; + const handler = this.toolHandlers.get(handlerName); if (!handler) { + // Try MCP tools + if (this.mcpManager?.isMcpTool(toolName)) { + const parsedArgs = this.parseToolArguments(toolCall.function.arguments); + const args = parsedArgs.ok ? parsedArgs.args : {}; + return this.mcpManager.executeMcpTool(toolName, args); + } return { ok: false, name: toolName, - error: `Unknown tool: ${toolName}` + error: `Unknown tool: ${toolName}`, }; } @@ -173,7 +215,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: parsedArgs.error + error: parsedArgs.error, }; } @@ -184,14 +226,18 @@ export class ToolExecutor { toolCall, createOpenAIClient: this.createOpenAIClient, onProcessStart: hooks?.onProcessStart, - onProcessExit: hooks?.onProcessExit + onProcessExit: hooks?.onProcessExit, + onProcessStdout: hooks?.onProcessStdout, + onProcessTimeoutControl: hooks?.onProcessTimeoutControl, + onBeforeFileMutation: hooks?.onBeforeFileMutation, + onAfterFileMutation: hooks?.onAfterFileMutation, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: toolName, - error: message + error: message, }; } } @@ -215,7 +261,7 @@ export class ToolExecutor { ok: false, error: `InputParseError: Failed to parse tool arguments: ${message}. ` + - "Ensure the tool call arguments are valid JSON. Prefer Edit over Write for large existing-file changes." + "Ensure the tool call arguments are valid JSON. Prefer Edit over Write for large existing-file changes.", }; } } @@ -223,7 +269,7 @@ export class ToolExecutor { private formatToolResult(result: ToolExecutionResult): string { const payload: Record = { ok: result.ok, - name: result.name + name: result.name, }; if (typeof result.output !== "undefined") { @@ -244,5 +290,4 @@ export class ToolExecutor { return JSON.stringify(payload, null, 2); } - } diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 9d5223a..964cdd7 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -1,13 +1,9 @@ import * as fs from "fs"; import * as path from "path"; import ignore from "ignore"; -import type { - ToolExecutionContext, - ToolExecutionFollowUpMessage, - ToolExecutionResult -} from "./executor"; -import { readTextFileWithMetadata } from "./file-utils"; -import { createSnippet, markFileRead } from "./state"; +import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; +import { readTextFileWithMetadata } from "../common/file-utils"; +import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "../common/state"; const DEFAULT_LINE_LIMIT = 2000; const MAX_LINE_LENGTH = 2000; @@ -36,7 +32,7 @@ const DEFAULT_GITIGNORE = [ "*.class", "*.jar", "*.war", - "target/" + "target/", ]; type PageRange = { @@ -61,28 +57,26 @@ export async function handleReadTool( args: Record, context: ToolExecutionContext ): Promise { - let filePath = typeof args.file_path === "string" ? args.file_path : ""; + let filePath = typeof args.file_path === "string" ? normalizeFilePath(args.file_path) : ""; if (!filePath.trim()) { return { ok: false, name: "read", - error: "Missing required \"file_path\" string." + error: 'Missing required "file_path" string.', }; } - if (!path.isAbsolute(filePath)) { + if (!isAbsoluteFilePath(filePath)) { if (filePath.startsWith("../") || filePath.startsWith("..\\")) { return { ok: false, name: "read", - error: "file_path must be an absolute path." + error: "file_path must be an absolute path.", }; } const normalizedSuffix = normalizeRelativeSuffix(filePath); const isIgnored = loadGitignoreMatcher(context.projectRoot); - const matches = normalizedSuffix - ? findSuffixMatches(context.projectRoot, normalizedSuffix, isIgnored) - : []; + const matches = normalizedSuffix ? findSuffixMatches(context.projectRoot, normalizedSuffix, isIgnored) : []; if (matches.length > 1) { return { ok: false, @@ -90,7 +84,7 @@ export async function handleReadTool( error: "file_path must be an absolute path. " + `The file_path is ambiguous and may refer to multiple files:\n${matches.slice(0, 3).join("\n")}` + - (matches.length > 3 ? `\n...and ${matches.length - 3} more.` : "") + (matches.length > 3 ? `\n...and ${matches.length - 3} more.` : ""), }; } @@ -100,15 +94,13 @@ export async function handleReadTool( return { ok: false, name: "read", - error: - "file_path must be an absolute path. " + - `The file_path "${filePath}" is ambiguous.` + error: "file_path must be an absolute path. " + `The file_path "${filePath}" is ambiguous.`, }; } else { return { ok: false, name: "read", - error: `File not found: ${filePath}` + error: `File not found: ${filePath}`, }; } } @@ -120,7 +112,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `File not found: ${filePath}` + error: `File not found: ${filePath}`, }; } @@ -132,7 +124,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `Failed to stat file: ${message}` + error: `Failed to stat file: ${message}`, }; } @@ -140,7 +132,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "file_path points to a directory. Use bash ls for directories." + error: "file_path points to a directory. Use bash ls for directories.", }; } @@ -151,12 +143,12 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true + isPartialView: true, }); return { ok: true, name: "read", - output + output, }; } @@ -170,7 +162,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF has ${pageCount} pages; provide \"pages\" to read a range.` + error: `PDF has ${pageCount} pages; provide "pages" to read a range.`, }; } @@ -178,7 +170,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.` + error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.`, }; } @@ -186,7 +178,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF page range exceeds total page count (${pageCount}).` + error: `PDF page range exceeds total page count (${pageCount}).`, }; } @@ -194,7 +186,7 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true + isPartialView: true, }); return { ok: true, @@ -205,8 +197,8 @@ export async function handleReadTool( encoding: "base64", bytes: buffer.length, pageCount, - pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null - } + pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null, + }, }; } @@ -216,7 +208,7 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true + isPartialView: true, }); return { ok: true, @@ -224,11 +216,9 @@ export async function handleReadTool( output: "File loaded.", metadata: { mime, - bytes: buffer.length + bytes: buffer.length, }, - followUpMessages: [ - buildImageFollowUpMessage(filePath, mime, buffer) - ] + followUpMessages: [buildImageFollowUpMessage(filePath, mime, buffer)], }; } @@ -238,14 +228,14 @@ export async function handleReadTool( return { ok: false, name: "read", - error: offset.error + error: offset.error, }; } if (!limit.ok) { return { ok: false, name: "read", - error: limit.error + error: limit.error, }; } @@ -254,13 +244,10 @@ export async function handleReadTool( content: textResult.content, timestamp: textResult.timestamp, offset: textResult.isPartialView ? textResult.startLine : undefined, - limit: - textResult.isPartialView - ? Math.max(1, textResult.endLine - textResult.startLine + 1) - : undefined, + limit: textResult.isPartialView ? Math.max(1, textResult.endLine - textResult.startLine + 1) : undefined, isPartialView: textResult.isPartialView, encoding: textResult.encoding, - lineEndings: textResult.lineEndings + lineEndings: textResult.lineEndings, }); const snippet = createSnippet( context.sessionId, @@ -279,17 +266,17 @@ export async function handleReadTool( id: snippet.id, filePath: snippet.filePath, startLine: snippet.startLine, - endLine: snippet.endLine - } + endLine: snippet.endLine, + }, } - : undefined + : undefined, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "read", - error: message + error: message, }; } } @@ -339,9 +326,7 @@ function findSuffixMatches( return matches; } -function loadGitignoreMatcher( - projectRoot: string -): ((relPath: string, isDir: boolean) => boolean) | null { +function loadGitignoreMatcher(projectRoot: string): ((relPath: string, isDir: boolean) => boolean) | null { const gitignorePath = path.join(projectRoot, ".gitignore"); if (!fs.existsSync(gitignorePath)) { const ig = ignore(); @@ -400,9 +385,7 @@ function parseLineNumber( return { ok: true, value: integer }; } -function parseLineLimit( - value: unknown -): { ok: true; value: number } | { ok: false; error: string } { +function parseLineLimit(value: unknown): { ok: true; value: number } | { ok: false; error: string } { if (value === undefined || value === null) { return { ok: true, value: DEFAULT_LINE_LIMIT }; } @@ -430,7 +413,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView: false, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp + timestamp: metadata.timestamp, }; } @@ -445,7 +428,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView: false, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp + timestamp: metadata.timestamp, }; } @@ -464,7 +447,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp + timestamp: metadata.timestamp, }; } @@ -479,19 +462,7 @@ function formatWithLineNumbers(lines: string[], startLineNumber: number): string } function isImageExtension(ext: string): boolean { - return [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".tif", - ".tiff", - ".svg", - ".ico", - ".avif" - ].includes(ext); + return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".svg", ".ico", ".avif"].includes(ext); } function getImageMimeType(ext: string): string { @@ -520,25 +491,20 @@ function getImageMimeType(ext: string): string { } } -function buildImageFollowUpMessage( - filePath: string, - mime: string, - buffer: Buffer -): ToolExecutionFollowUpMessage { +function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffer): ToolExecutionFollowUpMessage { const fileName = path.basename(filePath); return { role: "system", content: - `The read tool has loaded \`${fileName}\`. ` + - "Use the attached image content to answer the original request.", + `The read tool has loaded \`${fileName}\`. ` + "Use the attached image content to answer the original request.", contentParams: [ { type: "image_url", image_url: { - url: `data:${mime};base64,${buffer.toString("base64")}` - } - } - ] + url: `data:${mime};base64,${buffer.toString("base64")}`, + }, + }, + ], }; } @@ -558,7 +524,7 @@ function parsePageRange(input: string): PageRange { throw new Error("pages must be a non-empty string."); } if (trimmed.includes(",")) { - throw new Error("pages must be a single range like \"1-5\" or \"3\"."); + throw new Error('pages must be a single range like "1-5" or "3".'); } const parts = trimmed.split("-").map((part) => part.trim()); @@ -576,7 +542,7 @@ function parsePageRange(input: string): PageRange { return { start, end, count: end - start + 1 }; } - throw new Error("pages must be a single range like \"1-5\" or \"3\"."); + throw new Error('pages must be a single range like "1-5" or "3".'); } function parsePositiveInt(value: string, label: string): number { @@ -618,8 +584,7 @@ function readNotebook(filePath: string): string { const outputs = Array.isArray(cell.outputs) ? cell.outputs : []; outputs.forEach((output, outputIndex) => { - const outputType = - typeof output.output_type === "string" ? output.output_type : "output"; + const outputType = typeof output.output_type === "string" ? output.output_type : "output"; lines.push(`# Output ${outputIndex + 1} (${outputType})`); lines.push(...formatNotebookOutput(output)); }); diff --git a/src/tools/update-plan-handler.ts b/src/tools/update-plan-handler.ts new file mode 100644 index 0000000..7c7198e --- /dev/null +++ b/src/tools/update-plan-handler.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { executeValidatedTool } from "../common/runtime"; + +const updatePlanSchema = z.strictObject({ + plan: z.string().trim().min(1, "plan must not be empty."), + explanation: z.string().trim().optional(), +}); + +export async function handleUpdatePlanTool( + args: Record, + _context: ToolExecutionContext +): Promise { + return executeValidatedTool("UpdatePlan", updatePlanSchema, args, _context, async (input) => ({ + ok: true, + name: "UpdatePlan", + output: "Plan updated.", + metadata: { + plan: input.plan, + ...(input.explanation ? { explanation: input.explanation } : {}), + }, + })); +} diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 26f4a56..b3dde69 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -27,6 +27,7 @@ type LLMClientContext = { thinkingEnabled: boolean; notify?: string; webSearchTool?: string; + env?: Record; machineId?: string; }; @@ -39,14 +40,14 @@ export async function handleWebSearchTool( return { ok: false, name: "WebSearch", - error: "Missing required \"query\" string." + error: 'Missing required "query" string.', }; } const llmContext = context.createOpenAIClient?.(); const scriptPath = llmContext?.webSearchTool?.trim(); if (scriptPath) { - return executeConfiguredWebSearch(query, scriptPath, context); + return executeConfiguredWebSearch(query, scriptPath, context, llmContext?.env ?? {}); } if (!hasUsableClient(llmContext)) { @@ -54,7 +55,7 @@ export async function handleWebSearchTool( ok: false, name: "WebSearch", error: - "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json." + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json or ./.deepcode/settings.json.", }; } @@ -68,9 +69,10 @@ function hasUsableClient(value: ReturnType | undefined): val async function executeConfiguredWebSearch( query: string, scriptPath: string, - context: ToolExecutionContext + context: ToolExecutionContext, + configuredEnv: Record ): Promise { - const execution = await runWebSearchScript(scriptPath, query, context); + const execution = await runWebSearchScript(scriptPath, query, context, configuredEnv); const output = execution.stdout.slice(0, MAX_OUTPUT_CHARS); const truncated = execution.stdout.length > MAX_OUTPUT_CHARS; @@ -84,8 +86,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated - } + truncated, + }, }; } @@ -99,8 +101,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated - } + truncated, + }, }; } @@ -112,8 +114,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, truncated, - stderr: execution.stderr || undefined - } + stderr: execution.stderr || undefined, + }, }; } @@ -124,11 +126,7 @@ async function executeDefaultWebSearch( ): Promise { try { const prepared = await prepareSearchQuery(query, llmContext); - const output = await runDefaultWebSearchRequest( - prepared.resolvedQuery, - llmContext.machineId, - context - ); + const output = await runDefaultWebSearchRequest(prepared.resolvedQuery, llmContext.machineId, context); return { ok: true, @@ -139,15 +137,15 @@ async function executeDefaultWebSearch( resolvedQuery: prepared.resolvedQuery, translated: prepared.translated, dominantLanguage: prepared.decision.dominantLanguage, - languageReason: prepared.decision.reason - } + languageReason: prepared.decision.reason, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "WebSearch", - error: `WebSearch default mode failed: ${message}` + error: `WebSearch default mode failed: ${message}`, }; } } @@ -155,13 +153,14 @@ async function executeDefaultWebSearch( async function runWebSearchScript( scriptPath: string, query: string, - context: ToolExecutionContext + context: ToolExecutionContext, + configuredEnv: Record ): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> { return new Promise((resolve) => { const child = spawn(scriptPath, [query], { cwd: context.projectRoot, - env: process.env, - stdio: ["ignore", "pipe", "pipe"] + env: { ...process.env, ...configuredEnv }, + stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; if (typeof pid === "number") { @@ -192,7 +191,7 @@ async function runWebSearchScript( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error + error, }); }); }); @@ -208,7 +207,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: translatedQuery, decision, - translated: true + translated: true, }; } } @@ -219,7 +218,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: translatedQuery, decision, - translated: true + translated: true, }; } } @@ -227,7 +226,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: query, decision, - translated: false + translated: false, }; } @@ -235,10 +234,7 @@ function containsChineseChar(text: string): boolean { return /[\u4e00-\u9fff]/.test(text); } -async function decideSearchLanguage( - query: string, - llmContext: LLMClientContext -): Promise { +async function decideSearchLanguage(query: string, llmContext: LLMClientContext): Promise { const prompt = `Decide whether the topic below has more useful online material in English or Chinese. Topic: @@ -259,7 +255,7 @@ Do not include markdown or any extra text.`; return { dominantLanguage, - reason: typeof result.reason === "string" ? result.reason : "" + reason: typeof result.reason === "string" ? result.reason : "", }; } @@ -279,13 +275,15 @@ Query: ${query} \`\`\``; - return stripCodeFence(await chat(llmContext, prompt)).trim().replace(/^['"]|['"]$/g, ""); + return stripCodeFence(await chat(llmContext, prompt)) + .trim() + .replace(/^['"]|['"]$/g, ""); } async function chat(llmContext: LLMClientContext, prompt: string): Promise { const response = await llmContext.client.chat.completions.create({ model: llmContext.model, - messages: [{ role: "user", content: prompt }] + messages: [{ role: "user", content: prompt }], }); const content = response.choices?.[0]?.message?.content as unknown; @@ -337,16 +335,14 @@ async function runDefaultWebSearchRequest( method: "POST", headers: { "Content-Type": "application/json", - Token: machineId + Token: machineId, }, - body: JSON.stringify({ query }) + body: JSON.stringify({ query }), }); if (!response.ok) { const body = await response.text().catch(() => ""); - throw new Error( - `WebSearch API request failed with status ${response.status}${body ? `: ${body}` : ""}` - ); + throw new Error(`WebSearch API request failed with status ${response.status}${body ? `: ${body}` : ""}`); } const payload = (await response.json()) as { @@ -354,10 +350,6 @@ async function runDefaultWebSearchRequest( result?: unknown; }; - if (payload.success !== true) { - throw new Error("WebSearch API returned success=false."); - } - if (typeof payload.result === "string" && payload.result.trim()) { return payload.result.trim(); } @@ -381,9 +373,7 @@ function formatWebSearchActivityLabel(query: string): string { const normalizedQuery = query.replace(/\s+/g, " ").trim(); const maxQueryLength = 180; const clippedQuery = - normalizedQuery.length > maxQueryLength - ? `${normalizedQuery.slice(0, maxQueryLength - 3)}...` - : normalizedQuery; + normalizedQuery.length > maxQueryLength ? `${normalizedQuery.slice(0, maxQueryLength - 3)}...` : normalizedQuery; return `${WEB_SEARCH_TOOL_ACTIVITY_PREFIX} ${clippedQuery}`; } diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 344e1df..a4c81bf 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -1,5 +1,4 @@ import * as fs from "fs"; -import * as path from "path"; import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; import { @@ -8,21 +7,19 @@ import { hasFileChangedSinceState, normalizeContent, readTextFileWithMetadata, - writeTextFile -} from "./file-utils"; -import { executeValidatedTool } from "./runtime"; -import { getFileState, isFullFileView, normalizeFilePath, recordFileState } from "./state"; + writeTextFile, +} from "../common/file-utils"; +import { executeValidatedTool } from "../common/runtime"; +import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "../common/state"; const writeSchema = z.strictObject({ file_path: z.string().min(1, "file_path is required."), content: z.string({ - invalid_type_error: - "content must be a string. If you are writing JSON, serialize the full document to text before calling write." - }) + error: + "content must be a string. If you are writing JSON, serialize the full document to text before calling write.", + }), }); -type WriteInput = z.infer; - type WriteRepairMetadata = { input_repaired: boolean; repair_kind: "json-stringify-content"; @@ -41,11 +38,11 @@ export async function handleWriteTool( context, async (input) => { const filePath = normalizeFilePath(input.file_path); - if (!path.isAbsolute(filePath)) { + if (!isAbsoluteFilePath(filePath)) { return { ok: false, name: "write", - error: "file_path must be an absolute path." + error: "file_path must be an absolute path.", }; } @@ -59,7 +56,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: `Failed to stat file: ${message}` + error: `Failed to stat file: ${message}`, }; } @@ -67,7 +64,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "file_path points to a directory." + error: "file_path points to a directory.", }; } @@ -77,7 +74,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "Must read the full existing file before writing." + error: "Must read the full existing file before writing.", }; } @@ -85,7 +82,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "File has been modified since read. Read it again before writing." + error: "File has been modified since read. Read it again before writing.", }; } } @@ -98,24 +95,24 @@ export async function handleWriteTool( const existingMetadata = existingFile ? readTextFileWithMetadata(filePath) : null; const encoding = existingMetadata?.encoding ?? "utf8"; - const lineEndings = - existingMetadata?.lineEndings ?? - (input.content.includes("\r\n") ? "CRLF" : "LF"); - const diffPreview = buildDiffPreview( - filePath, - existingMetadata?.content ?? null, - normalizedContent - ); + const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF"); + const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); + context.onBeforeFileMutation?.(filePath); const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); + context.onAfterFileMutation?.(filePath); const freshMetadata = readTextFileWithMetadata(filePath); - recordFileState(context.sessionId, { - filePath, - content: freshMetadata.content, - timestamp: freshMetadata.timestamp, - encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings - }); + recordFileState( + context.sessionId, + { + filePath, + content: freshMetadata.content, + timestamp: freshMetadata.timestamp, + encoding: freshMetadata.encoding, + lineEndings: freshMetadata.lineEndings, + }, + { incrementVersion: true } + ); return { ok: true, @@ -129,22 +126,21 @@ export async function handleWriteTool( line_endings: freshMetadata.lineEndings, cache_refreshed: true, diff_preview: diffPreview, - ...repairMetadata - } + ...repairMetadata, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "write", - error: message + error: message, }; } }, { preprocess: (rawInput) => { - const filePath = - typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; + const filePath = typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; const content = rawInput.content; if ( filePath.toLowerCase().endsWith(".json") && @@ -154,7 +150,7 @@ export async function handleWriteTool( ) { repairMetadata = { input_repaired: true, - repair_kind: "json-stringify-content" + repair_kind: "json-stringify-content", }; return { @@ -162,19 +158,17 @@ export async function handleWriteTool( input: { ...rawInput, file_path: filePath, - content: JSON.stringify(content, null, 2) - } + content: JSON.stringify(content, null, 2), + }, }; } repairMetadata = null; return { ok: true, - input: typeof rawInput.file_path === "string" - ? { ...rawInput, file_path: filePath } - : rawInput + input: typeof rawInput.file_path === "string" ? { ...rawInput, file_path: filePath } : rawInput, }; - } + }, } ); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index deac0e7..ae94fa0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,74 +1,121 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Box, Static, Text, useApp, useStdout } from "ink"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; +import chalk from "chalk"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import OpenAI from "openai"; +import { createOpenAIClient } from "../common/openai-client"; import { - SessionManager, type LlmStreamProgress, + type MessageMeta, + type PermissionScope, type SessionEntry, + SessionManager, type SessionMessage, type SessionStatus, type SkillInfo, - type UserPromptContent + type UndoTarget, + type UserPromptContent, } from "../session"; -import { resolveSettings, type DeepcodingSettings } from "../settings"; -import { PromptInput, type PromptSubmission } from "./PromptInput"; -import { MessageView } from "./MessageView"; +import { + applyModelConfigSelection, + type DeepcodingSettings, + type ModelConfigSelection, + type ResolvedDeepcodingSettings, + resolveSettingsSources, +} from "../settings"; +import { PromptInput, type PromptDraft, type PromptSubmission } from "./PromptInput"; +import { MessageView, RawModeExitPrompt } from "./components"; import { SessionList } from "./SessionList"; +import { UndoSelector, type UndoRestoreMode } from "./UndoSelector"; import { buildLoadingText } from "./loadingText"; import { findExpandedThinkingId } from "./thinkingState"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; +import { McpStatusList } from "./McpStatusList"; +import { ProcessStdoutView } from "./ProcessStdoutView"; import { + type AskUserQuestionAnswers, findPendingAskUserQuestion, formatAskUserQuestionAnswers, - type AskUserQuestionAnswers } from "./askUserQuestion"; +import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; +import { buildExitSummaryText } from "./exitSummary"; +import { RawMode, useRawModeContext } from "./contexts"; +import { renderMessageToStdout } from "./components/MessageView/utils"; +import { renderRawModeMessages } from "./utils"; +import { ANSI_CLEAR_SCREEN } from "./constants"; const DEFAULT_MODEL = "deepseek-v4-pro"; const DEFAULT_BASE_URL = "https://api.deepseek.com"; -type View = "chat" | "session-list"; +type View = "chat" | "session-list" | "undo" | "mcp-status"; type AppProps = { projectRoot: string; - version?: string; + initialPrompt?: string; + onRestart?: () => void; }; -export function App({ projectRoot, version = "" }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); + const { columns, rows } = useWindowSize(); + const { mode, setMode } = useRawModeContext(); + const initialPromptSubmittedRef = useRef(false); + const processStdoutRef = useRef>(new Map()); + const rawModeRef = useRef(mode); + const writeRef = useRef(write); + const lastRenderedColumnsRef = useRef(null); + const messagesRef = useRef([]); const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); const [skills, setSkills] = useState([]); const [messages, setMessages] = useState([]); const [sessions, setSessions] = useState([]); + const [undoTargets, setUndoTargets] = useState([]); + const [promptDraft, setPromptDraft] = useState(null); const [statusLine, setStatusLine] = useState(""); const [errorLine, setErrorLine] = useState(null); const [streamProgress, setStreamProgress] = useState(null); const [runningProcesses, setRunningProcesses] = useState(null); const [activeStatus, setActiveStatus] = useState(null); + const [activeAskPermissions, setActiveAskPermissions] = useState(undefined); + const [pendingPermissionReply, setPendingPermissionReply] = useState<{ + sessionId: string; + permissions: PermissionPromptResult["permissions"]; + alwaysAllows: PermissionScope[]; + } | null>(null); const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); - const [, setNowTick] = useState(0); + const [isExiting, setIsExiting] = useState(false); + const [showWelcome, setShowWelcome] = useState(true); + const [welcomeNonce, setWelcomeNonce] = useState(0); + const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); + const [nowTick, setNowTick] = useState(0); + const [mcpStatuses, setMcpStatuses] = useState>([]); + const [showProcessStdout, setShowProcessStdout] = useState(false); - const messagesRef = useRef([]); + rawModeRef.current = mode; messagesRef.current = messages; const sessionManager = useMemo(() => { return new SessionManager({ projectRoot, - createOpenAIClient: () => createOpenAIClient(), - getResolvedSettings: () => resolveCurrentSettings(), + createOpenAIClient: () => createOpenAIClient(projectRoot), + getResolvedSettings: () => resolveCurrentSettings(projectRoot), renderMarkdown: (text) => text, onAssistantMessage: (message: SessionMessage) => { setMessages((prev) => [...prev, message]); + if (rawModeRef.current === RawMode.Raw) { + process.stdout.write("\n"); + process.stdout.write(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); + } }, onSessionEntryUpdated: (entry) => { setStatusLine(buildStatusLine(entry)); setRunningProcesses(entry.processes); setActiveStatus(entry.status); + setActiveAskPermissions(entry.askPermissions); }, onLlmStreamProgress: (progress) => { if (progress.phase === "end") { @@ -76,10 +123,54 @@ export function App({ projectRoot, version = "" }: AppProps): React.ReactElement return; } setStreamProgress(progress); - } + }, + onMcpStatusChanged: () => { + // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 + setMcpStatuses(sessionManager.getMcpStatus()); + }, + onProcessStdout: (pid, chunk) => { + const buf = processStdoutRef.current; + const current = buf.get(pid) ?? ""; + // Cap at 1 MB per process to avoid unbounded memory growth + // on noisy or long-running commands like `yes` or verbose builds. + const MAX_STDOUT_BUFFER = 1_000_000; + if (current.length >= MAX_STDOUT_BUFFER) { + return; + } + const text = typeof chunk === "string" ? chunk : String(chunk); + const available = MAX_STDOUT_BUFFER - current.length; + buf.set(pid, current + text.slice(0, available)); + }, }); }, [projectRoot]); + /** + * Navigate to a sub-view. + */ + const navigateToSubView = useCallback((targetView: View) => { + setShowWelcome(false); + setView(targetView); + }, []); + + /** + * Reset the static view to the welcome screen. + */ + const resetStaticView = useCallback( + (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }) => { + if (options?.clearScreen) { + process.stdout.write(ANSI_CLEAR_SCREEN); + } + setMessages([]); + setWelcomeNonce((n) => n + 1); + navigateToSubView("chat"); + setTimeout(() => { + setMessages(loadedMessages); + setShowWelcome(true); + }, 0); + }, + [navigateToSubView] + ); + useEffect(() => { if (!busy) { return; @@ -88,92 +179,166 @@ export function App({ projectRoot, version = "" }: AppProps): React.ReactElement return () => clearInterval(id); }, [busy]); - useEffect(() => { - refreshSessionsList(); - const list = sessionManager.listSessions(); - if (list.length > 0) { - const latest = list[0]; - sessionManager.setActiveSessionId(latest.id); - setMessages(loadVisibleMessages(sessionManager, latest.id)); - setStatusLine(buildStatusLine(latest)); - setRunningProcesses(latest.processes); - setActiveStatus(latest.status); - void refreshSkills(latest.id); - } else { - void refreshSkills(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - function loadVisibleMessages(manager: SessionManager, sessionId: string): SessionMessage[] { return manager.listSessionMessages(sessionId).filter((m) => m.visible); } - function refreshSessionsList(): void { + const refreshSessionsList = useCallback((): void => { setSessions(sessionManager.listSessions()); - } + }, [sessionManager]); - async function refreshSkills(sessionId?: string): Promise { - try { - const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); - setSkills(list); - } catch { - // ignore - } - } + const refreshSkills = useCallback( + async (sessionId?: string): Promise => { + try { + const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); + setSkills(list); + } catch { + // ignore + } + }, + [sessionManager] + ); + + /** + * Reset the app to the welcome screen. + */ + const resetToWelcome = useCallback(async () => { + writeRef.current(ANSI_CLEAR_SCREEN); + sessionManager.setActiveSessionId(null); + setStatusLine(""); + setErrorLine(null); + setRunningProcesses(null); + setActiveStatus(null); + setActiveAskPermissions(undefined); + setPendingPermissionReply(null); + setDismissedQuestionIds(new Set()); + resetStaticView([]); + await refreshSkills(); + }, [sessionManager, resetStaticView, refreshSkills]); + + /** + * Refresh the list of sessions. + */ + useEffect(() => { + refreshSessionsList(); + void refreshSkills(); + }, [refreshSessionsList, refreshSkills]); + + // Eagerly create the OpenAI client on mount so the TCP+TLS connection + // warmup (fire-and-forget inside createOpenAIClient) starts before the + // user sends their first prompt. + useEffect(() => { + createOpenAIClient(projectRoot); + }, [projectRoot]); + /** + * Initialize MCP servers. + */ + useLayoutEffect(() => { + const settings = resolveCurrentSettings(projectRoot); + void sessionManager.initMcpServers(settings.mcpServers); + }, [projectRoot, sessionManager]); + + /** + * Dispose the session manager on unmount. + */ + useEffect(() => { + return () => { + sessionManager.dispose(); + }; + }, [sessionManager]); + + writeRef.current = write; const handlePrompt = useCallback( async (submission: PromptSubmission) => { if (submission.command === "exit") { - exit(); - process.exit(0); + setIsExiting(true); + setTimeout(() => { + const activeSessionId = sessionManager.getActiveSessionId(); + const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + const summary = buildExitSummaryText({ session }); + process.stdout.write("\n"); + process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); + process.stdout.write("\n\n"); + process.stdout.write(summary); + process.stdout.write("\n\n"); + sessionManager.dispose(); + exit(); + }, 0); return; } if (submission.command === "new") { - write("\u001B[2J\u001B[3J\u001B[H"); - sessionManager.setActiveSessionId(null); - setMessages([]); - setStatusLine(""); - setErrorLine(null); - setRunningProcesses(null); - setActiveStatus(null); - setDismissedQuestionIds(new Set()); - await refreshSkills(); - refreshSessionsList(); + if (onRestart) { + onRestart(); + } else { + await resetToWelcome(); + refreshSessionsList(); + } return; } if (submission.command === "resume") { refreshSessionsList(); - setView("session-list"); + navigateToSubView("session-list"); + return; + } + if (submission.command === "continue" && isCurrentSessionEmpty(sessionManager)) { + refreshSessionsList(); + navigateToSubView("session-list"); + return; + } + if (submission.command === "undo") { + const activeSessionId = sessionManager.getActiveSessionId(); + if (!activeSessionId) { + setErrorLine("No active session to undo."); + return; + } + setUndoTargets(sessionManager.listUndoTargets(activeSessionId)); + navigateToSubView("undo"); + return; + } + if (submission.command === "mcp") { + setMcpStatuses(sessionManager.getMcpStatus()); + navigateToSubView("mcp-status"); return; } const prompt: UserPromptContent = { text: submission.text, imageUrls: submission.imageUrls, - skills: submission.selectedSkills && submission.selectedSkills.length > 0 - ? submission.selectedSkills - : undefined + skills: + submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, + permissions: submission.permissions, + alwaysAllows: submission.alwaysAllows, }; + const activeSessionId = sessionManager.getActiveSessionId(); + const permissionReply = + pendingPermissionReply && activeSessionId === pendingPermissionReply.sessionId ? pendingPermissionReply : null; + if (permissionReply) { + prompt.permissions = permissionReply.permissions; + prompt.alwaysAllows = permissionReply.alwaysAllows; + } const trimmedText = (submission.text ?? "").trim(); const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; - const userDisplayContent = trimmedText - || (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") - || (submission.imageUrls.length > 0 ? "[Image]" : ""); + const userDisplayContent = + trimmedText || + (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || + (submission.imageUrls.length > 0 ? "[Image]" : ""); - if (userDisplayContent) { - setMessages((prev) => [ - ...prev, - buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length) - ]); + if (userDisplayContent && submission.command !== "continue") { + setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]); } setBusy(true); setErrorLine(null); setRunningProcesses(null); + setShowProcessStdout(false); + processStdoutRef.current.clear(); try { await sessionManager.handleUserPrompt(prompt); + if (permissionReply) { + setPendingPermissionReply(null); + } await refreshSkills(); refreshSessionsList(); } catch (error) { @@ -185,28 +350,255 @@ export function App({ projectRoot, version = "" }: AppProps): React.ReactElement setRunningProcesses(null); } }, - [exit, sessionManager, write] + [ + sessionManager, + pendingPermissionReply, + exit, + onRestart, + refreshSkills, + refreshSessionsList, + navigateToSubView, + resetToWelcome, + ] ); const handleInterrupt = useCallback(() => { sessionManager.interruptActiveSession(); }, [sessionManager]); + const handleToggleProcessStdout = useCallback(() => { + setShowProcessStdout(true); + }, []); + + const handleDismissProcessStdout = useCallback(() => { + setShowProcessStdout(false); + }, []); + + const handleAdjustBashTimeout = useCallback( + (deltaMs: number) => sessionManager.adjustActiveBashTimeout(deltaMs), + [sessionManager] + ); + + const handleModelConfigChange = useCallback( + (selection: ModelConfigSelection): string => { + const current = resolveCurrentSettings(projectRoot); + const { changed } = writeModelConfigSelection(selection, current, projectRoot); + const next = resolveCurrentSettings(projectRoot); + setResolvedSettings(next); + + if (!changed) { + return "Model settings unchanged"; + } + + const activeSessionId = sessionManager.getActiveSessionId(); + const meta: MessageMeta = { + isModelChange: true, + }; + const content = `/model\n└ Set model to ${selection.model} (${selection?.thinkingEnabled ? selection?.reasoningEffort : "no thinking"})`; + + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, content, true, meta); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }, + ]); + } + + return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; + }, + [projectRoot, sessionManager] + ); + + const handleSubmit = useCallback( + (submission: PromptSubmission) => { + void handlePrompt(submission); + }, + [handlePrompt] + ); + + const reloadActiveSessionView = useCallback( + (sessionId: string): void => { + resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + }, + [resetStaticView, sessionManager] + ); + + useEffect(() => { + if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) { + return; + } + + initialPromptSubmittedRef.current = true; + handleSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + }, [handleSubmit, initialPrompt]); + const handleSelectSession = useCallback( async (sessionId: string) => { sessionManager.setActiveSessionId(sessionId); - setMessages(loadVisibleMessages(sessionManager, sessionId)); + // Clear first so resets its index to 0. + resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); setActiveStatus(session?.status ?? null); - setView("chat"); + setActiveAskPermissions(session?.askPermissions); + if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { + setPendingPermissionReply(null); + } await refreshSkills(sessionId); }, - [sessionManager] + [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] + ); + + const handleDeleteSession = useCallback( + async (id: string): Promise => { + const isActiveSession = sessionManager.getActiveSessionId() === id; + + // If the deleted session is the active one, clear the active session first + if (isActiveSession) { + sessionManager.setActiveSessionId(null); + } + + sessionManager.deleteSession(id); + refreshSessionsList(); + + if (isActiveSession) { + await resetToWelcome(); + } + }, + [sessionManager, refreshSessionsList, resetToWelcome] ); - const screenWidth = stdout?.columns ?? 80; + const handleUndoRestore = useCallback( + async (target: UndoTarget, restoreMode: UndoRestoreMode): Promise => { + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId) { + setErrorLine("No active session to undo."); + setView("chat"); + setShowWelcome(true); + return; + } + + const errors: string[] = []; + if (restoreMode === "code-and-conversation") { + try { + sessionManager.restoreSessionCode(sessionId, target.message.id); + } catch (error) { + errors.push(`Code restore failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + let conversationRestored = false; + try { + sessionManager.restoreSessionConversation(sessionId, target.message.id); + conversationRestored = true; + } catch (error) { + errors.push(`Conversation restore failed: ${error instanceof Error ? error.message : String(error)}`); + } + + refreshSessionsList(); + await refreshSkills(sessionId); + setView("chat"); + setErrorLine(errors.length > 0 ? errors.join(" ") : null); + if (conversationRestored) { + setPromptDraft(buildPromptDraftFromSessionMessage(target.message, Date.now())); + } + reloadActiveSessionView(sessionId); + }, + [reloadActiveSessionView, refreshSessionsList, refreshSkills, sessionManager] + ); + + const handleRawModeChange = useCallback( + (nextMode: string) => { + const activeSessionId = sessionManager.getActiveSessionId(); + setMode(nextMode as RawMode); + // Reset chat view state synchronously so the transition frame does not + // re-render a stale welcome screen before handleSelectSession runs. + setShowWelcome(false); + setMessages([]); + // Clear screen to remove stale formatted text. + process.stdout.write(ANSI_CLEAR_SCREEN); + + setTimeout(() => { + if (nextMode === RawMode.Raw) { + // Write all messages directly to stdout for raw scrollback mode. + const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; + renderRawModeMessages(allMessages, nextMode); + } else if (activeSessionId) { + // Switch to chat view to render messages. + handleSelectSession(activeSessionId); + } else { + // No active session: just show the welcome screen once. + setWelcomeNonce((n) => n + 1); + setShowWelcome(true); + } + }, 200); + }, + [handleSelectSession, sessionManager, setMode] + ); + + useEffect(() => { + if (!stdout?.isTTY) { + return; + } + if (columns <= 0) { + return; + } + if (lastRenderedColumnsRef.current === null) { + lastRenderedColumnsRef.current = columns; + return; + } + if (lastRenderedColumnsRef.current === columns) { + return; + } + lastRenderedColumnsRef.current = columns; + + if (mode === RawMode.Raw) { + // In raw mode, re-render all messages directly to stdout at the new width. + // Use process.stdout.write instead of writeRef to avoid Ink interference. + process.stdout.write(ANSI_CLEAR_SCREEN); + const activeSessionId = sessionManager.getActiveSessionId(); + const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; + renderRawModeMessages(allMessages, mode); + return; + } + + // Force full redraw on terminal resize to avoid stale wrapped rows. + writeRef.current("\u001B[2J\u001B[H"); + + setMessages([]); + setShowWelcome(false); + setWelcomeNonce((n) => n + 1); + + const activeSessionId = sessionManager.getActiveSessionId(); + const nextMessages = + activeSessionId && !busy ? loadVisibleMessages(sessionManager, activeSessionId) : messagesRef.current; + setTimeout(() => { + setMessages(nextMessages); + setShowWelcome(true); + }, 0); + }, [busy, mode, sessionManager, columns, stdout]); + + const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); + const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]); const promptHistory = useMemo(() => { return messages .filter((message) => message.role === "user" && typeof message.content === "string") @@ -214,23 +606,44 @@ export function App({ projectRoot, version = "" }: AppProps): React.ReactElement .filter((content) => content.length > 0); }, [messages]); const expandedThinkingId = findExpandedThinkingId(messages); - const pendingQuestion = useMemo( - () => findPendingAskUserQuestion(messages, activeStatus), - [activeStatus, messages] + const pendingQuestion = useMemo(() => findPendingAskUserQuestion(messages, activeStatus), [activeStatus, messages]); + const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId)); + const loadingText = useMemo( + () => (busy ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation + [busy, streamProgress, runningProcesses, nowTick] ); - const shouldShowQuestionPrompt = Boolean( - pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId) + + const welcomeItem: SessionMessage = useMemo( + () => ({ + id: `__welcome__${welcomeNonce}`, + sessionId: "", + role: "system", + content: "", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "", + updateTime: "", + }), + [welcomeNonce] ); - const loadingText = busy - ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) - : null; - const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); + const staticItems = useMemo(() => { + if (mode === RawMode.Raw) { + return []; + } + if (showWelcome && view === "chat") { + return [welcomeItem, ...messages]; + } + return messages; + }, [mode, showWelcome, view, messages, welcomeItem]); const handleQuestionAnswers = useCallback( (answers: AskUserQuestionAnswers) => { void handlePrompt({ text: formatAskUserQuestionAnswers(answers), - imageUrls: [] + imageUrls: [], }); }, [handlePrompt] @@ -243,25 +656,70 @@ export function App({ projectRoot, version = "" }: AppProps): React.ReactElement setDismissedQuestionIds((prev) => new Set(prev).add(pendingQuestion.messageId)); }, [pendingQuestion]); + const handlePermissionResult = useCallback( + (result: PermissionPromptResult) => { + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId) { + return; + } + if (result.hasDeny) { + setPendingPermissionReply({ + sessionId, + permissions: result.permissions, + alwaysAllows: result.alwaysAllows, + }); + setStatusLine("Permission denied. Add a reply, then press Enter to continue."); + setPromptDraft(null); + sessionManager.denySessionPermission(sessionId); + return; + } + void handlePrompt({ + text: "/continue", + imageUrls: [], + command: "continue", + permissions: result.permissions, + alwaysAllows: result.alwaysAllows, + }); + }, + [handlePrompt, sessionManager] + ); + + const handlePermissionCancel = useCallback(() => { + sessionManager.interruptActiveSession(); + setActiveStatus("interrupted"); + setActiveAskPermissions(undefined); + setPromptDraft(null); + refreshSessionsList(); + }, [refreshSessionsList, sessionManager]); + + if (mode === RawMode.Raw) { + return handleRawModeChange(prev)} />; + } + return ( - - {view === "chat" && messages.length === 0 ? ( - - ) : null} - - {(message) => ( - - )} + + + {(item) => { + if (item.id.startsWith("__welcome__")) { + return ( + + ); + } + return ( + + ); + }} {statusLine ? ( @@ -273,11 +731,41 @@ export function App({ projectRoot, version = "" }: AppProps): React.ReactElement Error: {errorLine} ) : null} - {view === "session-list" ? ( + {showProcessStdout ? ( + + ) : view === "session-list" ? ( void handleSelectSession(id)} onCancel={() => setView("chat")} + onDelete={(id) => { + void handleDeleteSession(id); + }} + /> + ) : view === "undo" ? ( + void handleUndoRestore(target, restoreMode)} + onCancel={() => { + setView("chat"); + setShowWelcome(true); + }} + /> + ) : view === "mcp-status" ? ( + setView("chat")} + onReconnect={(name) => { + const latest = resolveCurrentSettings(projectRoot); + void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]); + }} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( - ) : ( + ) : activeStatus === "ask_permission" && + activeAskPermissions && + activeAskPermissions.length > 0 && + !pendingPermissionReply && + !busy ? ( + + ) : isExiting ? null : ( void handlePrompt(submission)} + runningProcesses={runningProcesses} + promptDraft={promptDraft} + onSubmit={handleSubmit} + onModelConfigChange={handleModelConfigChange} + onRawModeChange={handleRawModeChange} onInterrupt={handleInterrupt} + onToggleProcessStdout={handleToggleProcessStdout} + placeholder="Type your message..." /> )} ); } +export default App; + function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { if (message.role !== "assistant") { return false; @@ -320,17 +829,46 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session imageCount > 0 ? Array.from({ length: imageCount }, () => ({ type: "image_url", - image_url: { url: "" } + image_url: { url: "" }, })) : null, messageParams: null, compacted: false, visible: true, createTime: now, - updateTime: now + updateTime: now, }; } +export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft { + return { + nonce, + text: typeof message.content === "string" ? message.content : "", + imageUrls: extractImageUrlsFromContentParams(message.contentParams), + }; +} + +function extractImageUrlsFromContentParams(contentParams: unknown): string[] { + const params = Array.isArray(contentParams) ? contentParams : contentParams ? [contentParams] : []; + const imageUrls: string[] = []; + for (const param of params) { + if (!param || typeof param !== "object") { + continue; + } + const record = param as { type?: unknown; image_url?: { url?: unknown } }; + const url = record.image_url?.url; + if (record.type === "image_url" && typeof url === "string" && url) { + imageUrls.push(url); + } + } + return imageUrls; +} + +function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { + const activeSessionId = sessionManager.getActiveSessionId(); + return !activeSessionId || !sessionManager.getSession(activeSessionId); +} + function buildStatusLine(entry: SessionEntry): string { const parts: string[] = []; parts.push(`status: ${entry.status}`); @@ -344,8 +882,15 @@ function buildStatusLine(entry: SessionEntry): string { } export function readSettings(): DeepcodingSettings | null { + return readSettingsFile(getUserSettingsPath()); +} + +export function readProjectSettings(projectRoot: string = process.cwd()): DeepcodingSettings | null { + return readSettingsFile(getProjectSettingsPath(projectRoot)); +} + +function readSettingsFile(settingsPath: string): DeepcodingSettings | null { try { - const settingsPath = path.join(os.homedir(), ".deepcode", "settings.json"); if (!fs.existsSync(settingsPath)) { return null; } @@ -356,67 +901,69 @@ export function readSettings(): DeepcodingSettings | null { } } -export function resolveCurrentSettings(): ReturnType { - return resolveSettings(readSettings(), { - model: DEFAULT_MODEL, - baseURL: DEFAULT_BASE_URL - }); +export function writeSettings(settings: DeepcodingSettings): void { + const settingsPath = getUserSettingsPath(); + writeSettingsFile(settingsPath, settings); } -export function createOpenAIClient(): { - client: OpenAI | null; - model: string; - baseURL: string; - thinkingEnabled: boolean; - reasoningEffort: "high" | "max"; - notify?: string; - webSearchTool?: string; - machineId?: string; -} { - const settings = resolveCurrentSettings(); - if (!settings.apiKey) { - return { - client: null, - model: settings.model, - baseURL: settings.baseURL, - thinkingEnabled: settings.thinkingEnabled, - reasoningEffort: settings.reasoningEffort, - notify: settings.notify, - webSearchTool: settings.webSearchTool, - machineId: getMachineId() - }; - } +export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { + const settingsPath = getProjectSettingsPath(projectRoot); + writeSettingsFile(settingsPath, settings); +} - const client = new OpenAI({ - apiKey: settings.apiKey, - baseURL: settings.baseURL || undefined - }); - return { - client, - model: settings.model, - baseURL: settings.baseURL, - thinkingEnabled: settings.thinkingEnabled, - reasoningEffort: settings.reasoningEffort, - notify: settings.notify, - webSearchTool: settings.webSearchTool, - machineId: getMachineId() - }; +function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); } -function getMachineId(): string | undefined { - try { - const idPath = path.join(os.homedir(), ".deepcode", "machine-id"); - if (fs.existsSync(idPath)) { - const raw = fs.readFileSync(idPath, "utf8").trim(); - if (raw) { - return raw; - } +export function writeModelConfigSelection( + selection: ModelConfigSelection, + current: ModelConfigSelection = resolveCurrentSettings(), + projectRoot: string = process.cwd() +): { changed: boolean; settings: DeepcodingSettings } { + const projectSettingsPath = getProjectSettingsPath(projectRoot); + const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); + const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); + const result = applyModelConfigSelection(rawSettings, current, selection); + if (result.changed) { + if (shouldWriteProjectSettings) { + writeProjectSettings(result.settings, projectRoot); + } else { + writeSettings(result.settings); } - const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`; - fs.mkdirSync(path.dirname(idPath), { recursive: true }); - fs.writeFileSync(idPath, generated, "utf8"); - return generated; - } catch { - return undefined; } + return result; +} + +export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { + return resolveSettingsSources( + readSettings(), + readProjectSettings(projectRoot), + { + model: DEFAULT_MODEL, + baseURL: DEFAULT_BASE_URL, + }, + process.env + ); +} + +export { createOpenAIClient } from "../common/openai-client"; + +function getUserSettingsPath(): string { + return path.join(os.homedir(), ".deepcode", "settings.json"); +} + +function getProjectSettingsPath(projectRoot: string): string { + return path.join(projectRoot, ".deepcode", "settings.json"); +} + +function formatThinkingMode(settings: Pick): string { + if (!settings.thinkingEnabled) { + return "no thinking"; + } + return `thinking ${settings.reasoningEffort}`; +} + +function formatModelConfig(settings: ModelConfigSelection): string { + return `${settings.model}, ${formatThinkingMode(settings)}`; } diff --git a/src/ui/AppContainer.tsx b/src/ui/AppContainer.tsx new file mode 100644 index 0000000..c8b3177 --- /dev/null +++ b/src/ui/AppContainer.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { AppContext } from "./contexts"; +import App from "./App"; +import { RawModeProvider } from "./contexts/RawModeContext"; + +const AppContainer: React.FC<{ + projectRoot: string; + version: string; + initialPrompt: string | undefined; + onRestart: () => void; +}> = ({ version, projectRoot, initialPrompt, onRestart }) => { + return ( + + + + + + ); +}; + +export default AppContainer; diff --git a/src/ui/AskUserQuestionPrompt.tsx b/src/ui/AskUserQuestionPrompt.tsx index 4d27f52..7c76ae3 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/AskUserQuestionPrompt.tsx @@ -70,16 +70,6 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return; } - if (key.tab || key.rightArrow) { - moveQuestion(1); - return; - } - - if (key.leftArrow) { - moveQuestion(-1); - return; - } - if (key.upArrow) { setCursorIndex((index) => Math.max(0, index - 1)); return; @@ -93,7 +83,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): if (key.backspace && isCurrentOther) { setOtherTexts((prev) => ({ ...prev, - [questionIndex]: (prev[questionIndex] ?? "").slice(0, -1) + [questionIndex]: (prev[questionIndex] ?? "").slice(0, -1), })); return; } @@ -108,7 +98,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): if (sanitized) { setOtherTexts((prev) => ({ ...prev, - [questionIndex]: `${prev[questionIndex] ?? ""}${sanitized}` + [questionIndex]: `${prev[questionIndex] ?? ""}${sanitized}`, })); } return; @@ -131,14 +121,6 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return null; } - function moveQuestion(direction: -1 | 1): void { - if (questions.length <= 1) { - return; - } - setQuestionIndex((index) => Math.max(0, Math.min(questions.length - 1, index + direction))); - setCursorIndex(0); - } - function toggleCurrentOption(): void { const value = options[cursorIndex]?.value; if (value) { @@ -149,9 +131,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): function toggleOption(value: string): void { setSelectedValues((prev) => { const current = prev[questionIndex] ?? []; - const next = current.includes(value) - ? current.filter((item) => item !== value) - : [...current, value]; + const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; return { ...prev, [questionIndex]: next }; }); } @@ -159,15 +139,17 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): function commitCurrentQuestion(): void { const answer = buildAnswerForQuestion(question, options[cursorIndex], selectedForQuestion, otherText); if (!answer) { - setStatusMessage(question.multiSelect - ? "Select at least one option with Space, or type an Other answer." - : "Select an option, or type an Other answer."); + setStatusMessage( + question.multiSelect + ? "Select at least one option with Space, or type an Other answer." + : "Select an option, or type an Other answer." + ); return; } const nextAnswers = { ...answers, - [question.question]: answer + [question.question]: answer, }; setAnswers(nextAnswers); @@ -183,10 +165,14 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return ( - Answer questions - {questionIndex + 1}/{questions.length} + + Answer questions + + + {" "} + {questionIndex + 1}/{questions.length} + - {questions.length > 1 ? : null} {question.question} {options.map((option, index) => { @@ -194,65 +180,51 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): const isSelected = option.isOther ? selectedForQuestion.includes(OTHER_VALUE) || Boolean(otherText.trim()) : selectedForQuestion.includes(option.value) || answers[question.question] === option.label; - const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : (isSelected ? "●" : "○"); + const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : isSelected ? "●" : "○"; return ( - {isCursor ? "› " : " "}{marker} {option.label} + {isCursor ? "> " : " "} + {marker} {option.label} {option.isOther ? ( - + {otherText ? ( - {otherText}{isCursor ? : null} + + {otherText} + {isCursor ? : null} + ) : ( {isCursor ? "type your answer here" : "type a custom answer"} )} ) : null} - {option.description ? ( - {option.description} - ) : null} + {option.description ? {option.description} : null} ); })} - {statusMessage ?? (isCurrentOther - ? "Type your answer · Backspace edit · Enter submit/next · ↑ choose presets · Esc type manually" - : question.multiSelect - ? "↑/↓ move · Space toggle · Enter submit/next · Tab switch · Esc type manually" - : "↑/↓ move · Enter select/next · Tab switch · Esc type manually")} + {statusMessage ?? + (isCurrentOther + ? "Type your answer · Backspace edit · Enter submit/next · ↑ choose presets · Esc type manually" + : question.multiSelect + ? "↑/↓ move · Space toggle · Enter submit/next · Esc type manually" + : "↑/↓ move · Enter select/next · Esc type manually")} ); } -function QuestionTabs({ - questions, - currentIndex, - answers -}: { - questions: AskUserQuestionItem[]; - currentIndex: number; - answers: AskUserQuestionAnswers; -}): React.ReactElement { - return ( - - {questions.map((question, index) => { - const answered = Boolean(answers[question.question]); - const label = ` ${answered ? "✓" : "□"} Q${index + 1} `; - return ( - - {label} - - ); - })} - - ); -} - function buildOptions(question: AskUserQuestionItem | undefined): OptionEntry[] { if (!question) { return []; @@ -261,13 +233,13 @@ function buildOptions(question: AskUserQuestionItem | undefined): OptionEntry[] ...question.options.map((option) => ({ label: option.label, description: option.description, - value: option.label + value: option.label, })), { label: "Other", value: OTHER_VALUE, - isOther: true - } + isOther: true, + }, ]; } diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx new file mode 100644 index 0000000..6593ff8 --- /dev/null +++ b/src/ui/DropdownMenu.tsx @@ -0,0 +1,195 @@ +import React, { useMemo } from "react"; +import { Box, Text } from "ink"; + +/** + * Generic dropdown menu item structure + */ +export type DropdownMenuItem = { + /** Unique key for React list rendering */ + key: string; + /** Main label text (can include status indicators) */ + label: string; + /** Secondary description text (dimmed) */ + description?: string; + /** Whether this item is currently selected */ + selected?: boolean; + /** Whether to show a special status indicator (e.g., loaded checkmark) */ + statusIndicator?: { + symbol: string; + color: string; + }; +}; + +/** + * Props for the DropdownMenu component + */ +type DropdownMenuProps = { + /** List of items to display */ + items: DropdownMenuItem[]; + /** Index of the currently active/highlighted item */ + activeIndex: number; + /** Maximum number of visible items before scrolling */ + maxVisible?: number; + /** Container width in columns */ + width: number; + /** Optional title displayed at the top */ + title?: string; + /** Color for the title (default: "magenta") */ + titleColor?: string; + /** Color for the active item indicator (default: "cyanBright") */ + activeColor?: string; + /** Help text displayed at the bottom */ + helpText?: string; + /** Text to display when items list is empty */ + emptyText?: string; + /** Custom item renderer (overrides default rendering) */ + renderItem?: (item: DropdownMenuItem, isActive: boolean) => React.ReactNode; +}; + +/** + * Calculate the visible window start position for scrolling + * Ensures the activeIndex is always visible within the window + */ +export function calculateVisibleStart(activeIndex: number, totalItems: number, maxVisible: number): number { + return Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, totalItems - maxVisible)); +} + +/** + * Generic dropdown menu component with scrolling support + * Used by Skills Dropdown, Model Dropdown, and other selection menus + */ +const DropdownMenu = React.memo(function DropdownMenu({ + items, + activeIndex, + maxVisible = 8, + width, + title, + titleColor = "magenta", + activeColor = "cyanBright", + helpText, + emptyText = "No items found", + renderItem, +}: DropdownMenuProps): React.ReactElement | null { + // Calculate visible window + const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); + const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); + + // 计算标签列最佳宽度:包含所有可能的前缀和后缀 + const labelColumnWidth = useMemo(() => { + if (visibleItems.length === 0) { + return 0; + } + // 计算每个 item 实际需要的最大宽度 + const maxContentWidth = Math.max( + ...visibleItems.map((item) => { + let width = 2; // prefix "> " or " " + if (item.selected !== undefined) { + width += 2; // "● " or "○ " + } + width += item.label.length; + if (item.statusIndicator) { + width += 2; // " ✓" or similar + } + return width; + }) + ); + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(maxContentWidth, maxAllowed); + }, [visibleItems, width]); + + // Early return if no items + if (items?.length === 0) { + return ( + + {title ? ( + + {title} + + ) : null} + {emptyText} + {helpText ? {helpText} : null} + + ); + } + + return ( + + {/* Title */} + {title ? ( + + + {title} + + + ) : null} + + {/* Scroll indicator - top */} + {visibleStart > 0 ? ( + + … {visibleStart} above + + ) : null} + + {/* Visible items */} + + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isActive = actualIndex === activeIndex; + + // Use custom renderer if provided + if (renderItem) { + return {renderItem(item, isActive)}; + } + + // Default rendering with selection indicator and optional features + return ( + + + + {isActive ? "> " : " "} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.statusIndicator ? ( + {item.statusIndicator.symbol} + ) : null} + + + {item.description ? {`${item.description}`} : null} + + ); + })} + + + {/* Scroll indicator - bottom */} + {visibleStart + visibleItems.length < items.length ? ( + + … {items.length - visibleStart - visibleItems.length} more + + ) : null} + + {/* Help text */} + {helpText ? ( + + {helpText} + + ) : null} + + ); +}); + +export default DropdownMenu; diff --git a/src/ui/McpStatusList.tsx b/src/ui/McpStatusList.tsx new file mode 100644 index 0000000..095612a --- /dev/null +++ b/src/ui/McpStatusList.tsx @@ -0,0 +1,564 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { McpServerStatus } from "../mcp/mcp-manager"; + +type Props = { + statuses: McpServerStatus[]; + onCancel: () => void; + onReconnect: (name: string) => void; +}; + +export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React.ReactElement { + const { columns, rows } = useWindowSize(); + + // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) + const [viewMode, setViewMode] = useState<"server-list" | "server-detail">("server-list"); + // 选中的服务器索引 + const [selectedServerIndex, setSelectedServerIndex] = useState(0); + + // 返回服务器列表 + const goBack = useCallback(() => { + setViewMode("server-list"); + }, []); + + // 进入服务器详情(允许 ready、failed、reconnecting 状态) + const enterDetail = useCallback(() => { + const server = statuses[selectedServerIndex]; + if (server && (server.status === "ready" || server.status === "failed" || server.status === "reconnecting")) { + setViewMode("server-detail"); + } + }, [statuses, selectedServerIndex]); + + // 当没有服务器时,监听 Esc 键退出 + useInput((input, key) => { + if (statuses.length === 0 && (key.escape || (key.ctrl && (input === "c" || input === "C")))) { + onCancel(); + } + }); + + if (statuses.length === 0) { + return ( + + + + Manage MCP servers + + 0 servers + + + No MCP servers configured. + Add MCP servers to your settings to get started. + + Esc to close + + ); + } + + if (viewMode === "server-detail") { + return ( + + ); + } + + return ( + + ); +} + +// ==================== 服务器列表视图 ==================== +function ServerListView({ + statuses, + selectedIndex, + onSelect, + onEnter, + onCancel, + rows, + columns, +}: { + statuses: McpServerStatus[]; + selectedIndex: number; + onSelect: (index: number) => void; + onEnter: () => void; + onCancel: () => void; + rows: number; + columns: number; +}): React.ReactElement { + const [scrollOffset, setScrollOffset] = useState(0); + const serverCount = statuses.length; + + const maxVisible = useMemo(() => { + const reservedLines = 8; // header + footer + borders + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + // 每个服务器占用 1 行(标题)+ 1 行(错误信息或统计)+ 1 行(间隔) + return Math.max(1, Math.floor(availableLines / 3)); + }, [rows]); + + // 计算标签列宽度:找到最长的服务器名称,加上前缀和图标 + const labelColumnWidth = useMemo(() => { + if (serverCount === 0) return 0; + const longestName = Math.max(...statuses.map((s) => s.name.length)); + const contentWidth = longestName + 5; // +2 for prefix "> " or " ", +3 for icon "✓ " + const maxAllowed = Math.max(15, Math.floor((columns - 6) * 0.4)); // 容器40%宽度,至少15列 + return Math.min(contentWidth, maxAllowed); + }, [statuses, serverCount, columns]); + + const safeIndex = useMemo(() => { + if (serverCount === 0) return 0; + return Math.max(0, Math.min(selectedIndex, serverCount - 1)); + }, [selectedIndex, serverCount]); + + // 自动滚动确保选中项可见 + React.useEffect(() => { + if (safeIndex < scrollOffset) { + setScrollOffset(safeIndex); + } else if (safeIndex >= scrollOffset + maxVisible) { + setScrollOffset(safeIndex - maxVisible + 1); + } + }, [safeIndex, scrollOffset, maxVisible]); + + const visibleServers = useMemo(() => { + return statuses.slice(scrollOffset, scrollOffset + maxVisible); + }, [statuses, scrollOffset, maxVisible]); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (serverCount === 0) { + return; + } + if (key.upArrow) { + onSelect(Math.max(0, selectedIndex - 1)); + return; + } + if (key.downArrow) { + onSelect(Math.min(serverCount - 1, selectedIndex + 1)); + return; + } + if (key.pageUp) { + onSelect(Math.max(0, selectedIndex - maxVisible)); + return; + } + if (key.pageDown) { + onSelect(Math.min(serverCount - 1, selectedIndex + maxVisible)); + return; + } + if (key.home) { + onSelect(0); + return; + } + if (key.end) { + onSelect(serverCount - 1); + } + // Enter 键进入详情 + if (key.return) { + onEnter(); + return; + } + }); + + const readyCount = statuses.filter((s) => s.status === "ready").length; + const startingCount = statuses.filter((s) => s.status === "starting").length; + const reconnectingCount = statuses.filter((s) => s.status === "reconnecting").length; + const failedCount = statuses.filter((s) => s.status === "failed").length; + + return ( + + + {/* Header row */} + + + Manage MCP servers + + + ( + + {readyCount} ready, + + + {startingCount} starting, + + {reconnectingCount > 0 && ( + + {reconnectingCount} reconnecting, + + )} + + {failedCount} failed + + ) + + + {/* Items list */} + + {visibleServers.map((status, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + + return ( + + ); + })} + {scrollOffset > 0 || scrollOffset + maxVisible < serverCount ? ( + + {scrollOffset > 0 ? … {scrollOffset} servers above. : null} + {scrollOffset + maxVisible < serverCount ? ( + … {serverCount - scrollOffset - maxVisible} servers below. + ) : null} + + ) : null} + + {/* Footer */} + + ↑/↓ navigate · Enter view details · Esc close + + + + ); +} + +function ServerRow({ + status, + selected, + labelColumnWidth, +}: { + status: McpServerStatus; + selected: boolean; + labelColumnWidth: number; +}): React.ReactElement { + const icon = + status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : status.status === "reconnecting" ? "↻" : "●"; + const color = + status.status === "ready" + ? "green" + : status.status === "failed" + ? "red" + : status.status === "reconnecting" + ? "#ff9900" + : "yellow"; + + // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... + const [dots, setDots] = React.useState(0); + React.useEffect(() => { + if (status.status !== "starting" && status.status !== "reconnecting") return; + const interval = setInterval(() => { + setDots((d) => (d + 1) % 4); + }, 500); + return () => clearInterval(interval); + }, [status.status]); + + const detail = + status.status === "ready" + ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` + : status.status === "failed" + ? `Failed` + : status.status === "reconnecting" + ? `Reconnecting${dots > 0 ? ".".repeat(dots) : " "}` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); + + return ( + + {/* Server row */} + + + + {selected ? "> " : " "} + {icon} + {status.name} + + + + {detail} + + + + {/* Error message for failed or reconnecting servers */} + {(status.status === "failed" || status.status === "reconnecting") && status.error ? ( + + ) : null} + + ); +} + +// ==================== 服务器详情视图 ==================== +function ServerDetailView({ + server, + onBack, + onCancel, + onReconnect, + rows, + columns, +}: { + server: McpServerStatus; + onBack: () => void; + onCancel: () => void; + onReconnect: (name: string) => void; + rows: number; + columns: number; +}): React.ReactElement { + const [activeIndex, setActiveIndex] = React.useState(0); + const hasReconnect = server.status === "failed"; + const canScroll = server.status === "ready"; + + // 合并所有 items(tools, prompts, resources)+ Reconnect 选项 + const allItems = useMemo(() => { + const items: { type: string; name: string }[] = []; + if (hasReconnect) { + items.push({ type: "action", name: "Reconnect" }); + } + server.tools.forEach((tool) => items.push({ type: "tool", name: tool })); + server.prompts.forEach((prompt) => items.push({ type: "prompt", name: prompt })); + server.resources.forEach((resource) => items.push({ type: "resource", name: resource })); + return items; + }, [server, hasReconnect]); + + const totalItems = allItems.length; + + const maxVisible = useMemo(() => { + const reservedLines = 12; // header + title + stats + error + footer + borders + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + return Math.max(1, availableLines); + }, [rows]); + + const visibleStartRef = React.useRef(0); + + const visibleStart = useMemo(() => { + if (totalItems === 0) return 0; + const currentStart = visibleStartRef.current; + let newStart = currentStart; + if (activeIndex < currentStart) { + newStart = activeIndex; + } else if (activeIndex >= currentStart + maxVisible) { + newStart = activeIndex - maxVisible + 1; + } + newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); + visibleStartRef.current = newStart; + return newStart; + }, [activeIndex, maxVisible, totalItems]); + + const visibleItems = allItems.slice(visibleStart, visibleStart + maxVisible); + + useInput((input, key) => { + if (key.ctrl && (input === "c" || input === "C")) { + onCancel(); + return; + } + if (key.escape) { + onBack(); + return; + } + if (key.return || input === " ") { + if (activeIndex === 0 && hasReconnect) { + onReconnect(server.name); + onBack(); + return; + } + onBack(); + return; + } + if (!canScroll && !hasReconnect) return; + if (key.upArrow) { + setActiveIndex((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1)); + return; + } + if (key.pageUp && canScroll) { + setActiveIndex((prev) => Math.max(0, prev - maxVisible)); + return; + } + if (key.pageDown && canScroll) { + setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible)); + return; + } + if (key.home && canScroll) { + setActiveIndex(0); + return; + } + if (key.end && canScroll) { + setActiveIndex(totalItems - 1); + } + }); + + const statusIcon = + server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●"; + const statusColor = + server.status === "ready" + ? "green" + : server.status === "failed" + ? "red" + : server.status === "reconnecting" + ? "#ff9900" + : "yellow"; + + return ( + + + {/* Header row */} + + {statusIcon} + + {server.name} + + — {server.status === "ready" ? "Details" : "Status"} + + {/* Server info */} + + + {server.status === "ready" + ? `${server.toolCount} tools, ${server.promptCount} prompts, ${server.resourceCount} resources` + : `Status: ${server.status}`} + + + {/* Error for failed/reconnecting */} + {server.error && (server.status === "failed" || server.status === "reconnecting") ? ( + + + + ) : null} + {/* Items list */} + + {visibleStart > 0 ? ( + + + + ) : ( + + )} + + {visibleItems.length === 0 ? ( + + No items available + + ) : ( + visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isSelected = actualIndex === activeIndex; + return ; + }) + )} + + {visibleStart > 0 || visibleStart + maxVisible < totalItems ? ( + + {totalItems - visibleStart - maxVisible > 0 ? : } + {visibleStart > 0 ? … {visibleStart} items above. : null} + {totalItems - visibleStart - maxVisible > 0 ? ( + … {totalItems - visibleStart - maxVisible} items below. + ) : null} + + ) : null} + + {/* Footer */} + + + {hasReconnect + ? "Enter to reconnect · Esc back · Ctrl+C close" + : canScroll + ? "↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close" + : "Space/Enter back · Esc back · Ctrl+C close"} + + + + + ); +} + +function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { + const isAction = item.type === "action"; + const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + const color = isAction && selected ? "#ff9900" : selected ? "#229ac3" : undefined; + + return ( + + {selected ? "> " : " "} + {icon} + + {isAction ? `[${item.name}]` : item.name} + + + ); +} + +function ErrorRow({ error }: { error: string }): React.ReactElement { + // 将错误消息按行分割,每行单独显示 + const lines = error.split("\n").filter((line) => line.trim().length > 0); + + return ( + + {lines.map((line, index) => ( + + + {line} + + + ))} + + ); +} diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx deleted file mode 100644 index 5bfd269..0000000 --- a/src/ui/MessageView.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; -import { renderMarkdown } from "./markdown"; -import type { SessionMessage } from "../session"; - -type Props = { - message: SessionMessage; - collapsed?: boolean; -}; - -export function MessageView({ message }: Props): React.ReactElement | null { - if (!message.visible) { - return null; - } - - if (message.role === "user") { - const text = message.content || "(no content)"; - return ( - - {`❯ ${text}`} - {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} - ) : null} - - ); - } - - if (message.role === "assistant") { - const isThinking = Boolean(message.meta?.asThinking); - const content = (message.content || "").trim(); - - if (isThinking) { - const summary = buildThinkingSummary(content, message.messageParams); - return ( - - - - ); - } - - return ( - - Assistant - - {content ? {renderMarkdown(content)} : null} - - - ); - } - - if (message.role === "tool") { - const summary = buildToolSummary(message); - const diffLines = getToolDiffPreviewLines(summary); - return ( - - - {diffLines.length > 0 ? : null} - - ); - } - - if (message.role === "system") { - if (message.meta?.skill) { - return ( - - ⚡ Loaded skill: {message.meta.skill.name} - - ); - } - if (message.meta?.isSummary) { - return ( - - (conversation summary inserted) - - ); - } - return null; - } - - return null; -} - -function StatusLine({ - bulletColor, - name, - params -}: { - bulletColor: "gray" | "green" | "red"; - name: string; - params: string; -}): React.ReactElement { - return ( - - {[ - , - " ", - {name}, - params ? {` ${params}`} : null - ]} - - ); -} - -function formatToolStatusParams(summary: ToolSummary): string { - const params = firstNonEmptyLine(summary.params); - return summary.name.toLowerCase() === "bash" ? params : truncate(params, 120); -} - -type ToolSummary = { - name: string; - params: string; - ok: boolean; - metadata: Record | null; -}; - -type DiffPreviewLine = { - marker: string; - content: string; - kind: "added" | "removed" | "context"; -}; - -function buildToolSummary(message: SessionMessage): ToolSummary { - const payload = parseToolPayload(message.content); - const metaFunctionName = - message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" - ? (message.meta.function as { name: string }).name - : null; - const name = payload.name || metaFunctionName || "tool"; - const params = name === "AskUserQuestion" - ? extractAskUserQuestionParams(message) || getMetaParams(message) - : getMetaParams(message); - - return { - name, - params, - ok: payload.ok !== false, - metadata: payload.metadata - }; -} - -function getMetaParams(message: SessionMessage): string { - return typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; -} - -function extractAskUserQuestionParams(message: SessionMessage): string { - const fromFunction = extractQuestionsFromToolFunction(message.meta?.function); - if (fromFunction) { - return fromFunction; - } - - const params = getMetaParams(message); - if (!params) { - return ""; - } - - try { - const parsed = JSON.parse(params); - return extractQuestionsFromValue(parsed); - } catch { - return ""; - } -} - -function extractQuestionsFromToolFunction(toolFunction: unknown): string { - if (!toolFunction || typeof toolFunction !== "object") { - return ""; - } - const args = (toolFunction as { arguments?: unknown }).arguments; - if (typeof args !== "string" || !args.trim()) { - return ""; - } - try { - const parsed = JSON.parse(args); - return extractQuestionsFromValue((parsed as { questions?: unknown })?.questions); - } catch { - return ""; - } -} - -function extractQuestionsFromValue(value: unknown): string { - if (!Array.isArray(value)) { - return ""; - } - return value - .map((item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) { - return ""; - } - return typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; - }) - .filter(Boolean) - .join(" / "); -} - -function parseToolPayload( - content: string | null -): { name: string | null; ok: boolean; metadata: Record | null } { - if (!content) { - return { name: null, ok: true, metadata: null }; - } - - try { - const parsed = JSON.parse(content) as { name?: unknown; ok?: unknown; metadata?: unknown }; - return { - name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, - ok: parsed.ok !== false, - metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null - }; - } catch { - return { name: null, ok: true, metadata: null }; - } -} - -function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { - if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { - return []; - } - const diffPreview = summary.metadata?.diff_preview; - if (typeof diffPreview !== "string" || !diffPreview.trim()) { - return []; - } - return parseDiffPreview(diffPreview); -} - -export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { - return diffPreview - .split("\n") - .filter((line) => line && !line.startsWith("--- ") && !line.startsWith("+++ ") && !line.startsWith("@@ ")) - .map((line) => { - if (line.startsWith("+")) { - return { marker: "+", content: line.slice(1), kind: "added" }; - } - if (line.startsWith("-")) { - return { marker: "-", content: line.slice(1), kind: "removed" }; - } - return { - marker: " ", - content: line.startsWith(" ") ? line.slice(1) : line, - kind: "context" - }; - }); -} - -function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { - return ( - - └ Changes - - {lines.map((line, index) => ( - - - {line.marker} - - - {line.content} - - - ))} - - - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function formatStatusName(value: string): string { - return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; -} - -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; - } - return `${value.slice(0, max)}…`; -} - -function firstNonEmptyLine(value: string): string { - for (const line of value.split(/\r?\n/)) { - const trimmed = line.trim().replace(/\s+/g, " "); - if (trimmed) { - return trimmed; - } - } - return ""; -} - -function buildThinkingSummary(content: string, messageParams: unknown | null): string { - if (content) { - const normalized = content.replace(/\r?\n/g, " ").replace(/\s+/g, " "); - let result = truncate(normalized, 100); - if (result.endsWith(":") || result.endsWith(":")) { - result = result.slice(0, -1); - } - return result; - } - - const params = messageParams as { reasoning_content?: unknown } | null | undefined; - if (typeof params?.reasoning_content === "string" && params.reasoning_content.trim()) { - return "(reasoning...)"; - } - - return ""; -} diff --git a/src/ui/PermissionPrompt.tsx b/src/ui/PermissionPrompt.tsx new file mode 100644 index 0000000..03881a5 --- /dev/null +++ b/src/ui/PermissionPrompt.tsx @@ -0,0 +1,274 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Box, Text } from "ink"; +import type { AskPermissionRequest, AskPermissionScope, PermissionScope, UserToolPermission } from "../session"; +import { useTerminalInput } from "./PromptInput"; + +export type PermissionPromptResult = { + permissions: UserToolPermission[]; + alwaysAllows: PermissionScope[]; + hasDeny: boolean; +}; + +type Props = { + requests: AskPermissionRequest[]; + onSubmit: (result: PermissionPromptResult) => void; + onCancel: () => void; +}; + +type ScopePrompt = { + request: AskPermissionRequest; + scope: AskPermissionScope; +}; + +type PromptOption = { + kind: "allow" | "always" | "deny"; + label: string; + scopeDescription?: string; + scopeColor?: string; +}; + +const ALWAYS_ALLOWED_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React.ReactElement | null { + const prompts = useMemo(() => buildScopePrompts(requests), [requests]); + const [index, setIndex] = useState(0); + const [cursor, setCursor] = useState(0); + const [decisions, setDecisions] = useState>({}); + const [alwaysAllows, setAlwaysAllows] = useState([]); + + const effectiveIndex = findNextPromptIndex(prompts, index, alwaysAllows); + const prompt = prompts[effectiveIndex] ?? null; + const options = prompt ? buildOptions(prompt.scope) : []; + + useEffect(() => { + setIndex(0); + setCursor(0); + setDecisions({}); + setAlwaysAllows([]); + }, [requests]); + + useEffect(() => { + if (!prompt) { + onSubmit(buildResult(requests, decisions, alwaysAllows)); + } + }, [alwaysAllows, decisions, onSubmit, prompt, requests]); + + useEffect(() => { + if (cursor >= options.length) { + setCursor(Math.max(0, options.length - 1)); + } + }, [cursor, options.length]); + + useTerminalInput((input, key) => { + if (!prompt) { + return; + } + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (key.upArrow) { + setCursor((value) => Math.max(0, value - 1)); + return; + } + if (key.downArrow) { + setCursor((value) => Math.min(options.length - 1, value + 1)); + return; + } + if (input && /^[1-3]$/.test(input)) { + const nextCursor = Number(input) - 1; + if (nextCursor >= 0 && nextCursor < options.length) { + commit(options[nextCursor]!.kind); + } + return; + } + if (key.return) { + commit(options[cursor]?.kind ?? "allow"); + } + }); + + if (!prompt) { + return null; + } + + function commit(kind: "allow" | "always" | "deny"): void { + if (!prompt) { + return; + } + if (kind === "always" && isAlwaysAllowedScope(prompt.scope)) { + const scope = prompt.scope; + setAlwaysAllows((prev) => (prev.includes(scope) ? prev : [...prev, scope])); + setDecisions((prev) => ({ + ...prev, + [prompt.request.toolCallId]: prev[prompt.request.toolCallId] === "deny" ? "deny" : "allow", + })); + } else { + setDecisions((prev) => ({ + ...prev, + [prompt.request.toolCallId]: + kind === "deny" ? "deny" : prev[prompt.request.toolCallId] === "deny" ? "deny" : "allow", + })); + } + setIndex(effectiveIndex + 1); + setCursor(0); + } + + return ( + + + + Permission required + + + {" "} + {Math.min(effectiveIndex + 1, prompts.length)}/{prompts.length} + + + {prompt.request.name} + {prompt.request.command} + {prompt.request.description ? {prompt.request.description} : null} + + Do you want to proceed? + + + {options.map((option, optionIndex) => ( + + {optionIndex === cursor ? "> " : " "} + {optionIndex + 1}. {renderOptionLabel(option)} + + ))} + + + ↑/↓ move · Enter select · Esc interrupt + + + ); +} + +function renderOptionLabel(option: PromptOption): React.ReactNode { + if (option.scopeDescription && option.scopeColor) { + return ( + <> + {option.label} + {option.scopeDescription} + + ); + } + return option.label; +} + +function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { + const prompts: ScopePrompt[] = []; + for (const request of requests) { + for (const scope of request.scopes.length > 0 ? request.scopes : ["unknown" as const]) { + prompts.push({ request, scope }); + } + } + return prompts; +} + +function buildOptions(scope: AskPermissionScope): PromptOption[] { + const options: PromptOption[] = [{ kind: "allow", label: "Yes" }]; + if (isAlwaysAllowedScope(scope)) { + options.push({ + kind: "always", + label: "Yes, and always allow ", + scopeDescription: describeScope(scope), + scopeColor: getScopeRiskColor(scope), + }); + } + options.push({ kind: "deny", label: "No" }); + return options; +} + +function findNextPromptIndex(prompts: ScopePrompt[], startIndex: number, alwaysAllows: PermissionScope[]): number { + let index = startIndex; + while (index < prompts.length) { + const scope = prompts[index]!.scope; + if (isAlwaysAllowedScope(scope) && alwaysAllows.includes(scope)) { + index += 1; + continue; + } + return index; + } + return prompts.length; +} + +function buildResult( + requests: AskPermissionRequest[], + decisions: Record, + alwaysAllows: PermissionScope[] +): PermissionPromptResult { + const permissions = requests.map((request) => ({ + toolCallId: request.toolCallId, + permission: decisions[request.toolCallId] === "deny" ? ("deny" as const) : ("allow" as const), + })); + return { + permissions, + alwaysAllows, + hasDeny: permissions.some((permission) => permission.permission === "deny"), + }; +} + +function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionScope { + return ALWAYS_ALLOWED_SCOPES.has(scope); +} + +export function getScopeRiskColor(scope: AskPermissionScope): string { + switch (scope) { + case "read-in-cwd": + case "query-git-log": + return "#22c55e"; + case "read-out-cwd": + case "write-in-cwd": + case "network": + case "mcp": + return "#f59e0b"; + case "write-out-cwd": + case "delete-in-cwd": + case "delete-out-cwd": + case "mutate-git-log": + case "unknown": + return "#ef4444"; + default: + return "#ef4444"; + } +} + +function describeScope(scope: PermissionScope): string { + switch (scope) { + case "read-in-cwd": + return "reads inside this workspace"; + case "read-out-cwd": + return "reads outside this workspace"; + case "write-in-cwd": + return "writes inside this workspace"; + case "write-out-cwd": + return "writes outside this workspace"; + case "delete-in-cwd": + return "deletes inside this workspace"; + case "delete-out-cwd": + return "deletes outside this workspace"; + case "query-git-log": + return "Git history queries"; + case "mutate-git-log": + return "Git history changes"; + case "network": + return "network access"; + case "mcp": + return "MCP tool access"; + default: + return scope; + } +} diff --git a/src/ui/ProcessStdoutView.tsx b/src/ui/ProcessStdoutView.tsx new file mode 100644 index 0000000..bc76a2f --- /dev/null +++ b/src/ui/ProcessStdoutView.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import { BASH_TIMEOUT_DECREMENT_MS, BASH_TIMEOUT_INCREMENT_MS } from "../common/bash-timeout"; +import type { BashTimeoutAdjustment, SessionEntry, SessionProcessEntry } from "../session"; +import { useTerminalInput } from "./prompt"; + +type RunningProcesses = SessionEntry["processes"]; + +type ProcessStdoutViewProps = { + processStdoutRef: React.MutableRefObject>; + runningProcesses: RunningProcesses; + onDismiss: () => void; + onAdjustTimeout: (deltaMs: number) => BashTimeoutAdjustment | null; + screenWidth: number; + screenHeight: number; +}; + +const REFRESH_INTERVAL_MS = 150; +const MAX_PANEL_HEIGHT = 30; +const MIN_PANEL_HEIGHT = 5; + +export const ProcessStdoutView = React.memo(function ProcessStdoutView({ + processStdoutRef, + runningProcesses, + onDismiss, + onAdjustTimeout, + screenWidth, + screenHeight, +}: ProcessStdoutViewProps): React.ReactElement { + const [stdoutText, setStdoutText] = useState(""); + const [scrollOffset, setScrollOffset] = useState(0); + const [statusMessage, setStatusMessage] = useState(""); + const statusTimerRef = useRef | null>(null); + + const panelHeight = Math.max(MIN_PANEL_HEIGHT, Math.min(screenHeight - 1, MAX_PANEL_HEIGHT)); + const reservedRows = statusMessage ? 2 : 1; + const visibleLineLimit = Math.max(1, panelHeight - reservedRows); + + useEffect(() => { + const updateStdout = () => { + let text = ""; + if (runningProcesses && runningProcesses.size > 0) { + for (const [pid, proc] of runningProcesses.entries()) { + const pidNum = Number(pid); + const stdout = processStdoutRef.current.get(pidNum) ?? ""; + if (text) { + text += "\n"; + } + if (runningProcesses.size > 1) { + text += `── Process ${pid} [${proc.command}] ──\n`; + } + text += stdout || "(no output yet)"; + } + } else { + text = "(no running processes)"; + } + setStdoutText(text); + }; + + updateStdout(); + const interval = setInterval(updateStdout, REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [processStdoutRef, runningProcesses]); + + useEffect(() => { + return () => { + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current); + } + }; + }, []); + + const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]); + const timeoutProcess = useMemo(() => getLatestTimeoutProcess(runningProcesses), [runningProcesses]); + + const visibleLines = useMemo(() => { + if (lines.length <= visibleLineLimit) { + return lines; + } + const outputLineLimit = Math.max(1, visibleLineLimit - 1); + const start = Math.max(0, lines.length - outputLineLimit - scrollOffset); + const slice = lines.slice(start, start + outputLineLimit); + if (lines.length > visibleLineLimit) { + slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`); + } + return slice; + }, [lines, scrollOffset, visibleLineLimit]); + + const setTemporaryStatus = (message: string) => { + setStatusMessage(message); + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current); + } + statusTimerRef.current = setTimeout(() => setStatusMessage(""), 2000); + }; + + useTerminalInput( + (input, key) => { + if ((key.ctrl && (input === "o" || input === "O")) || key.escape) { + onDismiss(); + return; + } + if (input === "+") { + const adjustment = onAdjustTimeout(BASH_TIMEOUT_INCREMENT_MS); + setTemporaryStatus(formatAdjustmentStatus(adjustment)); + return; + } + if (input === "-") { + const adjustment = onAdjustTimeout(-BASH_TIMEOUT_DECREMENT_MS); + setTemporaryStatus(formatAdjustmentStatus(adjustment)); + return; + } + if (key.upArrow) { + setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - visibleLineLimit))); + return; + } + if (key.downArrow) { + setScrollOffset((s) => Math.max(s - 10, 0)); + return; + } + if (key.pageUp) { + setScrollOffset((s) => Math.min(s + visibleLineLimit, Math.max(0, lines.length - visibleLineLimit))); + return; + } + if (key.pageDown) { + setScrollOffset((s) => Math.max(s - visibleLineLimit, 0)); + return; + } + }, + { isActive: true } + ); + + return ( + + + 📟 Process Output + {` (${formatTimeoutHint( + timeoutProcess?.entry + )} · +/- adjust · Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)`} + + + {visibleLines.map((line, index) => ( + {line} + ))} + + {statusMessage ? ( + + {statusMessage} + + ) : null} + + ); +}); + +function getLatestTimeoutProcess( + runningProcesses: RunningProcesses +): { pid: string; entry: SessionProcessEntry } | null { + if (!runningProcesses) { + return null; + } + let latest: { pid: string; entry: SessionProcessEntry } | null = null; + for (const [pid, entry] of runningProcesses.entries()) { + if (typeof entry.timeoutMs !== "number") { + continue; + } + latest = { pid, entry }; + } + return latest; +} + +function formatTimeoutHint(entry?: SessionProcessEntry): string { + if (!entry || typeof entry.timeoutMs !== "number") { + return "timeout unavailable"; + } + return `timeout ${formatDuration(entry.timeoutMs)}`; +} + +function formatAdjustmentStatus(adjustment: BashTimeoutAdjustment | null): string { + if (!adjustment) { + return "No adjustable Bash timeout"; + } + return `Timeout set to ${formatDuration(adjustment.timeoutMs)}`; +} + +function formatDuration(ms: number): string { + const totalMinutes = Math.max(1, Math.round(ms / 60000)); + return `${totalMinutes}m`; +} diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 8f9d4a9..8c808e9 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,13 +1,21 @@ -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useApp, useStdin, useStdout } from "ink"; +import React, { useEffect, useMemo, useState } from "react"; +import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; +import { ARGS_SEPARATOR } from "./constants"; import { EMPTY_BUFFER, - PromptBufferState, + PASTE_MARKER_REGEX, backspace, + cleanPasteContent, deleteForward, + deletePasteMarkerBackward, + deletePasteMarkerForward, deleteWordBefore, + deleteWordAfter, + expandPasteMarkers, + findPasteMarkerContaining, getCurrentSlashToken, + hasActivePasteMarkers, insertText, isEmpty, killLine, @@ -18,83 +26,118 @@ import { moveRight, moveWordLeft, moveWordRight, - moveUp + moveUp, } from "./promptBuffer"; +import type { PromptBufferState } from "./promptBuffer"; import { - SlashCommandItem, - buildSlashCommands, - filterSlashCommands, - findExactSlashCommand, - formatSlashCommandDescription, - formatSlashCommandLabel -} from "./slashCommands"; -import { readClipboardImage } from "./clipboard"; -import type { SkillInfo } from "../session"; + clearPromptUndoRedoState, + createPromptUndoRedoState, + recordPromptEdit, + redoPromptEdit, + undoPromptEdit, +} from "./promptUndoRedo"; +import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; +import type { SlashCommandItem } from "./slashCommands"; +import { + filterFileMentionItems, + getCurrentFileMentionToken, + replaceCurrentFileMentionToken, + scanFileMentionItems, +} from "./fileMentions"; +import type { FileMentionItem } from "./fileMentions"; +import { readClipboardImageAsync } from "./clipboard"; +import type { PermissionScope, SessionEntry, SkillInfo, UserToolPermission } from "../session"; + +// Re-exported from prompt modules for backward compatibility +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt"; +export type { InputKey } from "./prompt"; + +import { useTerminalInput } from "./prompt"; +import type { InputKey } from "./prompt"; +import { + useHiddenTerminalCursor, + useTerminalExtendedKeys, + useBracketedPaste, + useTerminalFocusReporting, +} from "./prompt"; +import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; +import type { ModelConfigSelection } from "../settings"; +import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "exit"; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; +}; + +export type PromptDraft = { + nonce: number; + text: string; + imageUrls: string[]; }; type Props = { + projectRoot: string; skills: SkillInfo[]; + modelConfig: ModelConfigSelection; + screenWidth: number; promptHistory: string[]; busy: boolean; loadingText?: string | null; disabled?: boolean; + placeholder?: string; + runningProcesses?: SessionEntry["processes"]; + promptDraft?: PromptDraft | null; onSubmit: (submission: PromptSubmission) => void; + onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onRawModeChange?: (mode: string) => void; onInterrupt: () => void; + onToggleProcessStdout?: () => void; }; -const BACKSPACE_BYTES = new Set(["", ""]); -const FORWARD_DELETE_SEQUENCES = new Set(["[3~", ""]); -const HOME_SEQUENCES = new Set(["", "[1~", "[7~", "OH"]); -const END_SEQUENCES = new Set(["", "[4~", "[8~", "OF"]); -const SHIFT_RETURN_SEQUENCES = new Set(["\r", ""]); -const META_RETURN_SEQUENCES = new Set(["", ""]); -const CTRL_LEFT_SEQUENCES = new Set(["", ""]); -const CTRL_RIGHT_SEQUENCES = new Set(["", ""]); -const META_LEFT_SEQUENCES = new Set(["", "", "b"]); -const META_RIGHT_SEQUENCES = new Set(["", "", "f"]); -const TERMINAL_FOCUS_IN = ""; -const TERMINAL_FOCUS_OUT = ""; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export type InputKey = { - upArrow: boolean; - downArrow: boolean; - leftArrow: boolean; - rightArrow: boolean; - home: boolean; - end: boolean; - pageDown: boolean; - pageUp: boolean; - return: boolean; - escape: boolean; - ctrl: boolean; - shift: boolean; - tab: boolean; - backspace: boolean; - delete: boolean; - meta: boolean; - focusIn: boolean; - focusOut: boolean; -}; +const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { + const [spinnerIndex, setSpinnerIndex] = useState(0); -export function PromptInput({ + useEffect(() => { + if (!busy) { + setSpinnerIndex(0); + return; + } + const timer = setInterval(() => { + setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length); + }, 80); + return () => clearInterval(timer); + }, [busy]); + + const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; + return {prefix}; +}); + +export const PromptInput = React.memo(function PromptInput({ + projectRoot, skills, + modelConfig, + screenWidth, promptHistory, busy, loadingText, disabled, + placeholder, + runningProcesses, + promptDraft, onSubmit, - onInterrupt + onModelConfigChange, + onInterrupt, + onToggleProcessStdout, + onRawModeChange, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); - const screenWidth = Math.max(20, stdout?.columns ?? 80); const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); @@ -102,44 +145,93 @@ export function PromptInput({ const [pendingExit, setPendingExit] = useState(false); const [menuIndex, setMenuIndex] = useState(0); const [showSkillsDropdown, setShowSkillsDropdown] = useState(false); - const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); + const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false); + const [showModelDropdown, setShowModelDropdown] = useState(false); + const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); + const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); const [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); - const [spinnerIndex, setSpinnerIndex] = useState(0); - const lastCtrlDAt = useRef(0); + const lastCtrlDAt = React.useRef(0); + const undoRedoRef = React.useRef(createPromptUndoRedoState()); + const wasBusyRef = React.useRef(busy); + const hadFileMentionTokenRef = React.useRef(false); + const appliedDraftNonceRef = React.useRef(null); + const pastesRef = React.useRef>(new Map()); + const pasteCounterRef = React.useRef(0); + // Track expanded paste regions for toggle (Ctrl+O expand / collapse). + const expandedRegionsRef = React.useRef>( + new Map() + ); - const slashItems = useMemo(() => buildSlashCommands(skills), [skills]); + const fileMentionToken = getCurrentFileMentionToken(buffer); + const hasFileMentionToken = fileMentionToken !== null; + const fileMentionKey = fileMentionToken ? `${fileMentionToken.start}:${fileMentionToken.query}` : null; + const fileMentionMatches = React.useMemo( + () => (fileMentionToken ? filterFileMentionItems(fileMentionItems, fileMentionToken.query) : []), + [fileMentionItems, fileMentionToken] + ); + const showFileMentionMenu = + !showSkillsDropdown && + !showModelDropdown && + fileMentionToken !== null && + fileMentionKey !== dismissedFileMentionKey; + const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); - const slashMenu = showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []; + const slashMenu = React.useMemo( + () => + showSkillsDropdown || showModelDropdown || showFileMentionMenu + ? [] + : slashToken + ? filterSlashCommands(slashItems, slashToken) + : [], + [showSkillsDropdown, showModelDropdown, showFileMentionMenu, slashToken, slashItems] + ); const showMenu = slashMenu.length > 0; - const promptHistoryKey = useMemo(() => promptHistory.join("\0"), [promptHistory]); - const promptPrefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "❯ "; + const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); + const hasRunningProcess = runningProcesses && runningProcesses.size > 0; + const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current); + const hasExpandedRegions = expandedRegionsRef.current.size > 0; + const processOrPasteHint = hasRunningProcess + ? " · ctrl+o view output" + : hasCollapsedMarkers + ? " · ctrl+o expand" + : hasExpandedRegions + ? " · ctrl+o collapse" + : ""; const footerText = statusMessage ? statusMessage : busy ? loadingText && loadingText.trim() - ? loadingText - : "esc to interrupt · ctrl+c to cancel input" - : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; - const cursorPlacement = useMemo( - () => getPromptCursorPlacement(buffer, screenWidth, promptPrefix, footerText), - [buffer, footerText, promptPrefix, screenWidth] - ); - + ? `${loadingText}${processOrPasteHint}` + : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}` + : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; useTerminalFocusReporting(stdout, !disabled); - usePromptTerminalCursor(stdout, cursorPlacement, !disabled); + useTerminalExtendedKeys(stdout, !disabled); + useBracketedPaste(stdout, !disabled); + useHiddenTerminalCursor(stdout, !disabled); + + const refreshFileMentionItems = React.useCallback(() => { + setFileMentionItems(scanFileMentionItems(projectRoot)); + }, [projectRoot]); useEffect(() => { - if (!busy) { - setSpinnerIndex(0); - return; + refreshFileMentionItems(); + }, [refreshFileMentionItems]); + + useEffect(() => { + if (wasBusyRef.current && !busy) { + refreshFileMentionItems(); } - const timer = setInterval(() => { - setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length); - }, 80); - return () => clearInterval(timer); - }, [busy]); + wasBusyRef.current = busy; + }, [busy, refreshFileMentionItems]); + + useEffect(() => { + if (hasFileMentionToken && !hadFileMentionTokenRef.current) { + refreshFileMentionItems(); + } + hadFileMentionTokenRef.current = hasFileMentionToken; + }, [hasFileMentionToken, refreshFileMentionItems]); useEffect(() => { if (!showMenu) { @@ -152,10 +244,10 @@ export function PromptInput({ }, [slashMenu, showMenu, menuIndex]); useEffect(() => { - if (skillsDropdownIndex >= skills.length) { - setSkillsDropdownIndex(Math.max(0, skills.length - 1)); + if (!fileMentionKey) { + setDismissedFileMentionKey(null); } - }, [skills.length, skillsDropdownIndex]); + }, [fileMentionKey]); useEffect(() => { if (!statusMessage) { @@ -165,270 +257,341 @@ export function PromptInput({ return () => clearTimeout(timer); }, [statusMessage]); + useEffect(() => { + if (!promptDraft || appliedDraftNonceRef.current === promptDraft.nonce) { + return; + } + appliedDraftNonceRef.current = promptDraft.nonce; + setBuffer({ text: promptDraft.text, cursor: promptDraft.text.length }); + setImageUrls(promptDraft.imageUrls); + setSelectedSkills([]); + setShowSkillsDropdown(false); + setOpenRawModelDropdown(false); + setHistoryCursor(-1); + setDraftBeforeHistory(null); + clearPromptUndoRedoState(undoRedoRef.current); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + }, [promptDraft]); + useEffect(() => { setHistoryCursor(-1); setDraftBeforeHistory(null); }, [promptHistoryKey]); - useTerminalInput((input, key) => { - if (key.focusIn) { - setHasTerminalFocus(true); - return; - } - if (key.focusOut) { - setHasTerminalFocus(false); - return; - } + useTerminalInput( + (input, key) => { + if (key.focusIn) { + setHasTerminalFocus(true); + return; + } + if (key.focusOut) { + setHasTerminalFocus(false); + return; + } - if (disabled) { - return; - } + if (disabled) { + return; + } - if (key.escape) { - if (showSkillsDropdown) { - setShowSkillsDropdown(false); + if (key.escape) { + if (showFileMentionMenu) { + return; + } + if (busy) { + onInterrupt(); + setStatusMessage("Interrupting…"); + } return; } - if (busy) { - onInterrupt(); - setStatusMessage("Interrupting…"); + + if (key.ctrl && (input === "o" || input === "O")) { + if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) { + onToggleProcessStdout(); + } else { + expandPasteMarkerAtCursor(); + } + return; } - return; - } - if (key.ctrl && (input === "d" || input === "D")) { - if (!isEmpty(buffer)) { - updateBuffer((s) => deleteForward(s)); + if (key.ctrl && (input === "d" || input === "D")) { + if (!isEmpty(buffer)) { + updateBuffer((s) => deleteForward(s)); + return; + } + const now = Date.now(); + if (pendingExit && now - lastCtrlDAt.current < 2000) { + exit(); + return; + } + lastCtrlDAt.current = now; + setPendingExit(true); + setStatusMessage("press ctrl+d again to exit"); return; } - const now = Date.now(); - if (pendingExit && now - lastCtrlDAt.current < 2000) { - exit(); - process.exit(0); + + if (key.ctrl && (input === "c" || input === "C")) { + if (busy) { + onInterrupt(); + setStatusMessage("Interrupting…"); + } else if (!isEmpty(buffer)) { + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + } else { + setStatusMessage("press ctrl+d to exit"); + } return; } - lastCtrlDAt.current = now; - setPendingExit(true); - setStatusMessage("press ctrl+d again to exit"); - return; - } - if (key.ctrl && (input === "c" || input === "C")) { - if (busy) { - onInterrupt(); - setStatusMessage("Interrupting…"); - } else if (!isEmpty(buffer)) { - setBuffer(EMPTY_BUFFER); - } else { - setStatusMessage("press ctrl+d to exit"); + if (pendingExit && (!key.ctrl || (input !== "d" && input !== "D"))) { + setPendingExit(false); } - return; - } - if (pendingExit && (!key.ctrl || (input !== "d" && input !== "D"))) { - setPendingExit(false); - } + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + return; + } - if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { - exitHistoryBrowsing(); - } + if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { + exitHistoryBrowsing(); + } - if (showSkillsDropdown) { - if (key.upArrow) { - setSkillsDropdownIndex((idx) => (idx - 1 + Math.max(skills.length, 1)) % Math.max(skills.length, 1)); + if (key.paste) { + handlePaste(input); return; } - if (key.downArrow) { - setSkillsDropdownIndex((idx) => (idx + 1) % Math.max(skills.length, 1)); + + if (key.ctrl && (input === "v" || input === "V")) { + setStatusMessage("Reading clipboard..."); + readClipboardImageAsync() + .then((image) => { + if (image) { + setImageUrls((prev) => [...prev, image.dataUrl]); + setStatusMessage("Attached image from clipboard"); + } else { + setStatusMessage("No image found in clipboard"); + } + }) + .catch(() => { + setStatusMessage("Failed to read clipboard"); + }); return; } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - const skill = skills[skillsDropdownIndex]; - if (skill) { - toggleSelectedSkill(skill); + + if (isClearImageAttachmentsShortcut(input, key)) { + if (imageUrls.length > 0) { + setImageUrls([]); + setStatusMessage("Cleared attached images"); + } else { + setStatusMessage("No attached images to clear"); } return; } - if (key.tab) { - setShowSkillsDropdown(false); - return; - } - } - if (key.ctrl && (input === "v" || input === "V")) { - const image = readClipboardImage(); - if (image) { - setImageUrls((prev) => [...prev, image.dataUrl]); - setStatusMessage("Attached image from clipboard"); - } else { - setStatusMessage("No image found in clipboard"); - } - return; - } + const noModifier = !key.shift && !key.ctrl && !key.meta; + const returnAction = getPromptReturnKeyAction(key); + const isPlainReturn = returnAction === "submit"; - if (isClearImageAttachmentsShortcut(input, key)) { - if (imageUrls.length > 0) { - setImageUrls([]); - setStatusMessage("Cleared attached images"); - } else { - setStatusMessage("No attached images to clear"); + if (showFileMentionMenu) { + if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { + return; + } } - return; - } - const noModifier = !key.shift && !key.ctrl && !key.meta; - const isPlainReturn = key.return && !key.shift && !key.meta; + if (showMenu) { + if (key.upArrow) { + setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); + return; + } + if (key.downArrow) { + setMenuIndex((idx) => (idx + 1) % slashMenu.length); + return; + } + if (key.tab || returnAction === "submit") { + const selected = slashMenu[menuIndex]; + if (selected) { + handleSlashSelection(selected); + return; + } + } + } - if (busy && (isPlainReturn || (showMenu && key.tab))) { - setStatusMessage("wait for the current response or press esc to interrupt"); - return; - } + if (busy && isPlainReturn) { + setStatusMessage("wait for the current response or press esc to interrupt"); + return; + } - if (showMenu) { - if (key.upArrow) { - setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); + if (returnAction === "newline") { + updateBuffer((s) => insertText(s, "\n")); return; } - if (key.downArrow) { - setMenuIndex((idx) => (idx + 1) % slashMenu.length); + + if (returnAction === "submit") { + submitCurrentBuffer(); return; } - if (key.tab || (key.return && !key.shift && !key.meta)) { - const selected = slashMenu[menuIndex]; - if (selected) { - handleSlashSelection(selected); - return; - } + + if (key.delete) { + updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s)); + return; } - } - if (key.return) { - const isShiftEnter = key.shift || key.meta; - if (isShiftEnter) { - updateBuffer((s) => insertText(s, "\n")); + if (key.backspace) { + updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s)); return; } - submitCurrentBuffer(); - return; - } - if (key.delete) { - updateBuffer((s) => deleteForward(s)); - return; - } + if ((key.ctrl || key.meta) && key.leftArrow) { + updateBuffer((s) => moveWordLeft(s)); + return; + } - if (key.backspace) { - updateBuffer((s) => backspace(s)); - return; - } + if ((key.ctrl || key.meta) && key.rightArrow) { + updateBuffer((s) => moveWordRight(s)); + return; + } - if ((key.ctrl || key.meta) && key.leftArrow) { - updateBuffer((s) => moveWordLeft(s)); - return; - } + if (key.leftArrow) { + updateBuffer((s) => moveLeft(s)); + return; + } - if ((key.ctrl || key.meta) && key.rightArrow) { - updateBuffer((s) => moveWordRight(s)); - return; - } + if (key.rightArrow) { + updateBuffer((s) => moveRight(s)); + return; + } - if (key.leftArrow) { - updateBuffer((s) => moveLeft(s)); - return; - } + if (key.home) { + updateBuffer((s) => moveLineStart(s)); + return; + } - if (key.rightArrow) { - updateBuffer((s) => moveRight(s)); - return; - } + if (key.end) { + updateBuffer((s) => moveLineEnd(s)); + return; + } - if (key.home) { - updateBuffer((s) => moveLineStart(s)); - return; - } + if (key.upArrow) { + if (noModifier && (historyCursor !== -1 || buffer.cursor === 0) && promptHistory.length > 0) { + navigateHistory(-1); + return; + } + updateBuffer((s) => moveUp(s)); + return; + } - if (key.end) { - updateBuffer((s) => moveLineEnd(s)); - return; - } + if (key.downArrow) { + if (noModifier && (historyCursor !== -1 || buffer.cursor === buffer.text.length)) { + navigateHistory(1); + return; + } + updateBuffer((s) => moveDown(s)); + return; + } - if (key.upArrow) { - if (noModifier && (historyCursor !== -1 || buffer.cursor === 0) && promptHistory.length > 0) { + if (key.ctrl && (input === "p" || input === "P")) { navigateHistory(-1); return; } - updateBuffer((s) => moveUp(s)); - return; - } - - if (key.downArrow) { - if (noModifier && (historyCursor !== -1 || buffer.cursor === buffer.text.length)) { + if (key.ctrl && (input === "n" || input === "N")) { navigateHistory(1); return; } - updateBuffer((s) => moveDown(s)); - return; - } + if (key.ctrl && (input === "a" || input === "A")) { + updateBuffer((s) => moveLineStart(s)); + return; + } + if (key.ctrl && (input === "e" || input === "E")) { + updateBuffer((s) => moveLineEnd(s)); + return; + } + if (key.ctrl && (input === "b" || input === "B")) { + updateBuffer((s) => moveLeft(s)); + return; + } + if (key.ctrl && (input === "f" || input === "F")) { + updateBuffer((s) => moveRight(s)); + return; + } + if (key.meta && (input === "b" || input === "B")) { + updateBuffer((s) => moveWordLeft(s)); + return; + } + if (key.meta && (input === "f" || input === "F")) { + updateBuffer((s) => moveWordRight(s)); + return; + } + if (key.ctrl && (input === "k" || input === "K")) { + updateBuffer((s) => killLine(s)); + return; + } + if (key.ctrl && (input === "u" || input === "U")) { + updateBuffer(() => EMPTY_BUFFER); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + return; + } + if (key.ctrl && (input === "w" || input === "W")) { + updateBuffer((s) => deleteWordBefore(s)); + return; + } + if (key.meta && (input === "d" || input === "D")) { + updateBuffer((s) => deleteWordAfter(s)); + return; + } + if (key.meta && (input === "\u007F" || input === "\b")) { + updateBuffer((s) => deleteWordBefore(s)); + return; + } + if (key.ctrl && (input === "j" || input === "J")) { + updateBuffer((s) => insertText(s, "\n")); + return; + } + if (key.ctrl && key.shift && input === "-") { + redo(); + return; + } + if (key.ctrl && input === "-") { + undo(); + return; + } + if (input.startsWith("\u001B")) { + // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. + return; + } - if (key.ctrl && (input === "p" || input === "P")) { - navigateHistory(-1); - return; - } - if (key.ctrl && (input === "n" || input === "N")) { - navigateHistory(1); - return; - } - if (key.ctrl && (input === "a" || input === "A")) { - updateBuffer((s) => moveLineStart(s)); - return; - } - if (key.ctrl && (input === "e" || input === "E")) { - updateBuffer((s) => moveLineEnd(s)); - return; - } - if (key.ctrl && (input === "b" || input === "B")) { - updateBuffer((s) => moveLeft(s)); - return; - } - if (key.ctrl && (input === "f" || input === "F")) { - updateBuffer((s) => moveRight(s)); - return; - } - if (key.meta && (input === "b" || input === "B")) { - updateBuffer((s) => moveWordLeft(s)); - return; - } - if (key.meta && (input === "f" || input === "F")) { - updateBuffer((s) => moveWordRight(s)); - return; - } - if (key.ctrl && (input === "k" || input === "K")) { - updateBuffer((s) => killLine(s)); - return; - } - if (key.ctrl && (input === "u" || input === "U")) { - updateBuffer(() => EMPTY_BUFFER); - return; - } - if (key.ctrl && (input === "w" || input === "W")) { - updateBuffer((s) => deleteWordBefore(s)); - return; - } - if (key.ctrl && (input === "j" || input === "J")) { - updateBuffer((s) => insertText(s, "\n")); + if (input && !key.ctrl && !key.meta) { + // Normalize line endings from paste: \r\n (Windows) → \n, \r (old macOS/Enter) → \n. + // This preserves multi-line formatting when the user pastes content. + const sanitized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + updateBuffer((s) => insertText(s, sanitized)); + } + }, + { isActive: !disabled } + ); + + function undo(): void { + const previous = undoPromptEdit(undoRedoRef.current, buffer); + if (!previous) { return; } + exitHistoryBrowsing(); + setBuffer(previous); + } - if (input.startsWith("")) { - // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. + function redo(): void { + const next = redoPromptEdit(undoRedoRef.current, buffer); + if (!next) { return; } + exitHistoryBrowsing(); + setBuffer(next); + } - if (input && !key.ctrl && !key.meta) { - const sanitized = input.replace(/\r/g, ""); - updateBuffer((s) => insertText(s, sanitized)); - } - }, { isActive: !disabled }); + function clearUndoRedoStacks(): void { + clearPromptUndoRedoState(undoRedoRef.current); + } function exitHistoryBrowsing(): void { setHistoryCursor(-1); @@ -437,7 +600,86 @@ export function PromptInput({ function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void { exitHistoryBrowsing(); - setBuffer(updater); + setBuffer((current) => { + const next = updater(current); + recordPromptEdit(undoRedoRef.current, current, next); + return next; + }); + } + + function handlePaste(pastedText: string): void { + const totalChars = pastedText.length; + + if (totalChars <= 1000) { + const newlineCount = (pastedText.match(/\n/g) ?? []).length; + if (newlineCount <= 9) { + const clean = pastedText + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); + updateBuffer((s) => insertText(s, clean)); + return; + } + } + + // Large paste: store raw text, insert marker with line/char count. + const lineCount = (pastedText.match(/\n/g) ?? []).length + 1; + pasteCounterRef.current += 1; + const pasteId = pasteCounterRef.current; + pastesRef.current.set(pasteId, pastedText); + + const marker = + lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`; + + updateBuffer((s) => insertText(s, marker)); + } + + function expandPasteMarkerAtCursor(): void { + // First, try to collapse an already-expanded region at the cursor. + for (const [id, region] of expandedRegionsRef.current) { + if (buffer.cursor >= region.start && buffer.cursor <= region.end) { + // Collapse back to marker. + expandedRegionsRef.current.delete(id); + pastesRef.current.set(id, region.content); + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end); + return { text, cursor: region.start + region.marker.length }; + }); + }, 0); + return; + } + } + + // No expanded region at cursor — try to expand a paste marker. + const marker = findPasteMarkerContaining(buffer); + if (!marker) { + setStatusMessage("No paste marker at cursor"); + return; + } + const content = pastesRef.current.get(marker.id); + if (!content) { + setStatusMessage("Paste content not found"); + return; + } + + const pasteId = marker.id; + const originalMarker = buffer.text.slice(marker.start, marker.end); + pastesRef.current.delete(pasteId); + + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end); + const newEnd = marker.start + content.length; + expandedRegionsRef.current.set(pasteId, { + start: marker.start, + end: newEnd, + content, + marker: originalMarker, + }); + return { text, cursor: marker.start }; + }); + }, 0); } function navigateHistory(direction: -1 | 1): void { @@ -462,10 +704,29 @@ export function PromptInput({ } const text = promptHistory[nextCursor] ?? ""; - setBuffer({ text, cursor: direction < 0 ? 0 : text.length }); + setBuffer({ text, cursor: text.length }); setHistoryCursor(nextCursor); } + function insertFileMentionSelection(item: FileMentionItem): void { + if (!fileMentionToken) { + return; + } + updateBuffer((state) => replaceCurrentFileMentionToken(state, fileMentionToken, item.path)); + setDismissedFileMentionKey(null); + } + + function resetPromptInput(): void { + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + pasteCounterRef.current = 0; + } + function handleSlashSelection(item: SlashCommandItem): void { if (busy && item.kind !== "exit") { setStatusMessage("wait for the current response or press esc to interrupt"); @@ -483,24 +744,51 @@ export function PromptInput({ setShowSkillsDropdown(true); return; } + if (item.kind === "model") { + clearSlashToken(); + setShowSkillsDropdown(false); + setShowModelDropdown(true); + return; + } + if (item.kind === "raw") { + clearSlashToken(); + setOpenRawModelDropdown(true); + return; + } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); - setBuffer(EMPTY_BUFFER); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); + return; + } + if (item.kind === "init") { + onSubmit(buildInitPromptSubmission(selectedSkills)); + resetPromptInput(); return; } if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); - setBuffer(EMPTY_BUFFER); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); + return; + } + if (item.kind === "continue") { + onSubmit({ text: "/continue", imageUrls: [], command: "continue" }); + resetPromptInput(); + return; + } + if (item.kind === "undo") { + onSubmit({ text: "/undo", imageUrls: [], command: "undo" }); + resetPromptInput(); + return; + } + if (item.kind === "mcp") { + onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); + resetPromptInput(); return; } if (item.kind === "exit") { - onSubmit({ text: "", imageUrls: [], command: "exit" }); + onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); return; } } @@ -525,14 +813,11 @@ export function PromptInput({ } onSubmit({ - text: buffer.text, + text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, - selectedSkills + selectedSkills, }); - setBuffer(EMPTY_BUFFER); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); } function addSelectedSkill(skill: SkillInfo): void { @@ -546,17 +831,19 @@ export function PromptInput({ function clearSlashToken(): void { exitHistoryBrowsing(); setBuffer((state) => removeCurrentSlashToken(state)); + clearUndoRedoStacks(); } - const divider = "─".repeat(screenWidth); - const visibleSkillStart = Math.min( - Math.max(0, skillsDropdownIndex - 7), - Math.max(0, skills.length - 8) + const showFooterText = useMemo( + () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] ); - const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); + + const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; + const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join(ARGS_SEPARATOR)}` : ""; return ( - + {imageUrls.length > 0 ? ( {formatImageAttachmentStatus(imageUrls.length)} @@ -565,62 +852,68 @@ export function PromptInput({ ) : null} {selectedSkills.length > 0 ? ( - {formatSelectedSkillsStatus(selectedSkills)} + + {formatSelectedSkillsStatus(selectedSkills)} + (use /skills to edit) ) : null} - {showSkillsDropdown ? ( - - Select Skills - {skills.length === 0 ? ( - No skills found - ) : ( - visibleSkills.map((skill, idx) => { - const skillIndex = visibleSkillStart + idx; - const selected = isSkillSelected(selectedSkills, skill); - const active = skillIndex === skillsDropdownIndex; - return ( - - {active ? "› " : " "} - {selected ? "●" : "○"}{" "} - {skill.name} - {skill.isLoaded ? : null} - {` ${skill.path}`} - - ); - }) - )} - {visibleSkillStart > 0 ? … {visibleSkillStart} above : null} - {visibleSkillStart + visibleSkills.length < skills.length ? ( - … {skills.length - visibleSkillStart - visibleSkills.length} more - ) : null} - space toggle · enter toggle · esc close - - ) : null} - {showMenu ? ( - - {slashMenu.slice(0, 8).map((item, idx) => ( - - {idx === menuIndex ? "› " : " "} - {formatSlashCommandLabel(item)} - {formatSlashCommandDescription(item.description)} - - ))} - {slashMenu.length > 8 ? … {slashMenu.length - 8} more : null} - - ) : null} - {divider} - - {promptPrefix} - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus)} - - {divider} - - {footerText} + {/* Input */} + + + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + {inlineHint ? {inlineHint} : null} + onRawModeChange?.(mode)} + screenWidth={screenWidth} + /> + + setShowModelDropdown(false)} + onModelConfigChange={onModelConfigChange} + onStatusMessage={setStatusMessage} + /> + { + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); + } + }} + onSelect={insertFileMentionSelection} + /> + + {!showFooterText && ( + + {footerText} + + )} ); -} +}); export const IMAGE_ATTACHMENT_CLEAR_HINT = "ctrl+x clear images"; @@ -639,10 +932,6 @@ export function formatSelectedSkillsStatus(skills: SkillInfo[]): string { return `⚡ ${names.join(", ")}`; } -export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { - return skills.some((item) => item.name === skill.name); -} - export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { if (isSkillSelected(skills, skill)) { return skills; @@ -651,9 +940,15 @@ export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo } export function toggleSkillSelection(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { - return isSkillSelected(skills, skill) - ? skills.filter((item) => item.name !== skill.name) - : [...skills, skill]; + return isSkillSelected(skills, skill) ? skills.filter((item) => item.name !== skill.name) : [...skills, skill]; +} + +export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSubmission { + return { + text: "/init", + imageUrls: [], + selectedSkills: selectedSkills.length > 0 ? selectedSkills : undefined, + }; } export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { @@ -675,326 +970,141 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick void), - callback?: (error?: Error | null) => void -) => boolean; - -function usePromptTerminalCursor( - stdout: NodeJS.WriteStream | undefined, - placement: CursorPlacement, - isActive: boolean -): void { - const directWriteRef = useRef<((data: string) => void) | null>(null); - const activePlacementRef = useRef(null); - - useLayoutEffect(() => { - if (!stdout?.isTTY) { - return; - } - - const stream = stdout as NodeJS.WriteStream & { write: WriteFn }; - const originalWrite = stream.write; - const directWrite = (data: string) => { - originalWrite.call(stdout, data); - }; - const restorePromptCursor = () => { - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - }; - const patchedWrite: WriteFn = (...args) => { - restorePromptCursor(); - return originalWrite.apply(stdout, args); - }; - - directWriteRef.current = directWrite; - stream.write = patchedWrite; - - return () => { - restorePromptCursor(); - stream.write = originalWrite; - directWriteRef.current = null; - }; - }, [stdout]); - - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - const directWrite = directWriteRef.current; - if (!directWrite) { - return; - } - - directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); - activePlacementRef.current = placement; - - return () => { - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - }; - }, [isActive, placement.column, placement.rowsUp, stdout]); -} - -function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } +export type PromptReturnKeyAction = "submit" | "newline" | null; - stdout.write(enableTerminalFocusReporting()); - return () => { - stdout.write(disableTerminalFocusReporting()); - }; - }, [isActive, stdout]); +export function getPromptReturnKeyAction(key: Pick): PromptReturnKeyAction { + if (!key.return) { + return null; + } + if (key.shift || key.meta) { + return "newline"; + } + return "submit"; } -export function getPromptCursorPlacement( +export function renderBufferWithCursor( state: PromptBufferState, - screenWidth: number, - promptPrefix: string, - footerText: string -): CursorPlacement { - const width = Math.max(1, screenWidth); - const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); - const beforeCursor = state.text.slice(0, cursor); - const at = state.text[cursor]; - const displayText = beforeCursor + (typeof at === "undefined" || at === "\n" ? " " : at) + - (at === "\n" ? "\n" : "") + (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); - - const cursorPosition = measureTextPosition(beforeCursor, width, textWidth(promptPrefix)); - const promptRows = measureTextRows(displayText, width, textWidth(promptPrefix)); - const footerRows = 1 + measureTextRows(footerText, width, 0); - - return { - rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, - column: cursorPosition.column - }; -} - -function measureTextRows(text: string, width: number, initialColumn: number): number { - return measureTextPosition(text, width, initialColumn).row + 1; -} - -function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { - let row = 0; - let column = Math.min(initialColumn, width - 1); - - for (const char of Array.from(text)) { - if (char === "\n") { - row++; - column = Math.min(initialColumn, width - 1); - continue; - } + isFocused: boolean, + placeholder?: string, + validPastes?: Map +): string { + const text = state.text || ""; + const cursor = Math.max(0, Math.min(state.cursor, text.length)); + const validIds = validPastes ?? new Map(); - const charColumns = textWidth(char); - if (column + charColumns > width) { - row++; - column = Math.min(initialColumn, width - 1); - } - column += charColumns; - if (column >= width) { - row++; - column = Math.min(initialColumn, width - 1); + if (text.length === 0 && placeholder) { + if (!isFocused) { + return chalk.dim(` ${placeholder}`); } + return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } - return { row, column }; -} - -function textWidth(value: string): number { - let width = 0; - for (const char of Array.from(value.normalize())) { - width += characterWidth(char); + if (text.length === 0) { + return isFocused ? renderCursorCell(" ") : ""; } - return width; -} -function characterWidth(char: string): number { - const codePoint = char.codePointAt(0) ?? 0; - if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { - return 0; - } - if (codePoint >= 0x300 && codePoint <= 0x36f) { - return 0; - } - if ( - (codePoint >= 0x1100 && codePoint <= 0x115f) || - (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xfe10 && codePoint <= 0xfe19) || - (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0xffe0 && codePoint <= 0xffe6) - ) { - return 2; + if (!isFocused) { + return highlightPasteMarkersInText(text, validIds); } - return 1; -} - -function cursorUp(rows: number): string { - return rows > 0 ? `\u001B[${rows}A` : ""; -} - -function cursorDown(rows: number): string { - return rows > 0 ? `\u001B[${rows}B` : ""; -} - -function cursorForward(columns: number): string { - return columns > 0 ? `\u001B[${columns}C` : ""; -} - -function showCursor(): string { - return "\u001B[?25h"; -} - -function hideCursor(): string { - return "\u001B[?25l"; -} - -function enableTerminalFocusReporting(): string { - return "\u001B[?1004h"; -} -function disableTerminalFocusReporting(): string { - return "\u001B[?1004l"; + return renderFocusedText(text, cursor, validIds); } -export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean): string { - const text = state.text || ""; - const cursor = Math.max(0, Math.min(state.cursor, text.length)); - const before = text.slice(0, cursor); - const at = text[cursor]; - const after = text.slice(cursor + 1); - if (!isFocused) { - return text.endsWith("\n") ? `${text} ` : text; +function highlightPasteMarkersInText(s: string, validIds: Map): string { + if (!s.includes("[paste #")) return s; + PASTE_MARKER_REGEX.lastIndex = 0; + let result = ""; + let pos = 0; + let match: RegExpExecArray | null; + while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) { + result += s.slice(pos, match.index); + const id = Number.parseInt(match[1]!, 10); + result += validIds.has(id) ? chalk.yellow(match[0]) : match[0]; + pos = match.index + match[0].length; } - - if (typeof at === "undefined") { - return before + chalk.inverse(" "); - } - if (at === "\n") { - return before + chalk.inverse(" ") + "\n" + after; - } - return before + chalk.inverse(at) + after; + result += s.slice(pos); + return result.endsWith("\n") ? `${result} ` : result; } -export function useTerminalInput( - inputHandler: (input: string, key: InputKey) => void, - options: { isActive?: boolean } = {} -): void { - const { stdin, setRawMode, internal_exitOnCtrlC } = useStdin(); - const isActive = options.isActive ?? true; - - useEffect(() => { - if (!isActive) { - return; - } - setRawMode(true); - return () => { - setRawMode(false); - }; - }, [isActive, setRawMode]); - - useEffect(() => { - if (!isActive) { - return; - } - const handleData = (data: Buffer | string) => { - const { input, key } = parseTerminalInput(data); +/** + * Render focused text with paste-marker highlighting and cursor insertion. + * Scans through the entire string in one pass, so the cursor can land + * anywhere (including inside or at the boundary of a paste marker) and the + * marker will still be highlighted correctly. + */ +function renderFocusedText(text: string, cursor: number, validIds: Map): string { + let result = ""; + let pos = 0; + PASTE_MARKER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) { + const markerStart = match.index; + const markerEnd = match.index + match[0].length; + const id = Number.parseInt(match[1]!, 10); + const isReal = validIds.has(id); + + // 1. Non-marker segment before this marker. + result += renderTextSegmentWithCursor(text, pos, markerStart, cursor, false); + pos = markerStart; + + // 2. Marker segment — highlighted only if it corresponds to a real paste. + result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, isReal); + pos = markerEnd; + } - if (!(input === "c" && key.ctrl) || !internal_exitOnCtrlC) { - inputHandler(input, key); - } - }; + // 3. Remainder after the last marker. + result += renderTextSegmentWithCursor(text, pos, text.length, cursor, false); - stdin?.on("data", handleData); - return () => { - stdin?.off("data", handleData); - }; - }, [isActive, stdin, internal_exitOnCtrlC, inputHandler]); + return result; } -export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { - const raw = String(data); - let input = raw; - const key: InputKey = { - upArrow: raw === "\u001B[A", - downArrow: raw === "\u001B[B", - leftArrow: raw === "\u001B[D" || CTRL_LEFT_SEQUENCES.has(raw) || META_LEFT_SEQUENCES.has(raw), - rightArrow: raw === "\u001B[C" || CTRL_RIGHT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw), - home: HOME_SEQUENCES.has(raw), - end: END_SEQUENCES.has(raw), - pageDown: raw === "\u001B[6~", - pageUp: raw === "\u001B[5~", - return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), - escape: raw === "\u001B", - ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), - shift: SHIFT_RETURN_SEQUENCES.has(raw), - tab: raw === "\t" || raw === "\u001B[Z", - backspace: BACKSPACE_BYTES.has(raw), - delete: FORWARD_DELETE_SEQUENCES.has(raw), - meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), - focusIn: raw === TERMINAL_FOCUS_IN, - focusOut: raw === TERMINAL_FOCUS_OUT - }; - - if (input <= "\u001A" && !key.return) { - input = String.fromCharCode(input.charCodeAt(0) + "a".charCodeAt(0) - 1); - key.ctrl = true; +/** + * Render a segment of `text` from `start` to `end`. + * The cursor (if it falls inside this segment) is rendered as an inverse-video cell. + */ +function renderTextSegmentWithCursor( + text: string, + start: number, + end: number, + cursor: number, + highlighted: boolean +): string { + if (start >= end) return ""; + + const segText = text.slice(start, end); + const cursorRel = cursor - start; // relative cursor position inside this segment + + // Cursor not in this segment – just return the text. + if (cursorRel < 0 || cursorRel > segText.length) { + return highlighted ? chalk.yellow(segText) : segText; } - const isKnownEscapeSequence = - key.upArrow || - key.downArrow || - key.leftArrow || - key.rightArrow || - key.home || - key.end || - key.pageDown || - key.pageUp || - key.tab || - key.delete || - key.return || - key.ctrl || - key.meta || - key.focusIn || - key.focusOut; - - if (raw.startsWith("\u001B")) { - input = raw.slice(1); - key.meta = key.meta || !isKnownEscapeSequence; + // Cursor is exactly at `end` (which equals `segText.length`). + if (cursorRel === segText.length) { + return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" "); } - const isLatinUppercase = input >= "A" && input <= "Z"; - const isCyrillicUppercase = input >= "А" && input <= "Я"; - if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) { - key.shift = true; - } + // Cursor is somewhere inside the segment. + const at = segText[cursorRel]; - if (key.tab && input === "[Z") { - key.shift = true; + if (at === "\n") { + // Render newline as a space in the cursor cell, then output the actual newline. + const before = segText.slice(0, cursorRel); + const after = segText.slice(cursorRel + 1); + return before + renderCursorCell(" ") + "\n" + after; } - if (key.tab || key.backspace || key.delete) { - input = ""; + const before = segText.slice(0, cursorRel); + const after = segText.slice(cursorRel + 1); + if (highlighted) { + return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after); } + return before + renderCursorCell(at) + after; +} - return { input, key }; +// Use explicit ANSI instead of chalk.inverse so cursor rendering stays enabled +// in non-TTY environments such as tests, where Chalk may strip styling. +function renderCursorCell(value: string): string { + return `\u001B[7m${value}\u001B[27m`; } diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index f5c3117..2d83b84 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,37 +1,182 @@ -import React, { useState } from "react"; -import { Box, Text, useInput } from "ink"; -import type { SessionEntry } from "../session"; +import React, { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { SessionEntry, SessionStatus } from "../session"; +import { truncate } from "./components/MessageView/utils"; type Props = { sessions: SessionEntry[]; onSelect: (sessionId: string) => void; onCancel: () => void; + onDelete?: (sessionId: string) => void; }; -export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { +/** + * Filter sessions by a search query. + * Matches against summary, status, and failReason fields (case-insensitive). + * Returns all sessions when query is empty. + */ +export function filterSessions(sessions: SessionEntry[], query: string): SessionEntry[] { + if (!query.trim()) { + return sessions; + } + + const lowerQuery = query.toLowerCase().trim(); + return sessions.filter((session) => { + if (session.summary && session.summary.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.status.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.failReason && session.failReason.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.assistantReply && session.assistantReply.toLowerCase().includes(lowerQuery)) { + return true; + } + return false; + }); +} + +export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): React.ReactElement { const [index, setIndex] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState(null); + const { columns, rows } = useWindowSize(); + + // Filter sessions by search query + const filteredSessions = useMemo(() => filterSessions(sessions, searchQuery), [sessions, searchQuery]); + + // Reset index when filtered list changes (e.g., query changes) + const safeIndex = useMemo(() => { + if (filteredSessions.length === 0) return 0; + return Math.max(0, Math.min(index, filteredSessions.length - 1)); + }, [index, filteredSessions.length]); + + // Dynamically calculate the number of visible sessions based on terminal height + const maxVisibleSessions = useMemo(() => { + // Subtract space used by borders, header (2 lines with search bar), footer, scroll indicator, etc. + // Outer container height=rows-1, outer border 2 + header 2 + search bar 1 + inner border 2 + footer 1 + scroll indicator 1 = 9 + const reservedLines = searchQuery ? 12 : 9; + const linesPerSession = 3; // height=2 + marginBottom=1 + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + return Math.max(1, Math.floor(availableLines / linesPerSession)); + }, [rows, searchQuery]); + + // Calculate scroll offset to keep the selected item visible + const scrollOffset = useMemo(() => { + if (safeIndex < maxVisibleSessions) return 0; + return safeIndex - maxVisibleSessions + 1; + }, [safeIndex, maxVisibleSessions]); + + // Get the currently visible session list + const visibleSessions = useMemo(() => { + return filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); + }, [filteredSessions, scrollOffset, maxVisibleSessions]); + + // Handle backspace for search query + const handleBackspace = useCallback(() => { + setSearchQuery((prev) => prev.slice(0, -1)); + setIndex(0); + }, []); + + const selectedSession = filteredSessions[safeIndex]; useInput((input, key) => { - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + // If in delete confirmation mode, handle confirm/cancel + if (confirmDeleteSessionId) { + if (key.return) { + onDelete?.(confirmDeleteSessionId); + setConfirmDeleteSessionId(null); + return; + } + if (key.escape) { + setConfirmDeleteSessionId(null); + return; + } + return; + } + + // ESC: clear search first, then cancel + if (key.escape) { + if (searchQuery) { + setSearchQuery(""); + setIndex(0); + return; + } onCancel(); return; } + + // Ctrl+C also cancels + if (key.ctrl && (input === "c" || input === "C")) { + onCancel(); + return; + } + + // Delete key: remove search character, or start delete confirmation + if (key.delete || key.backspace) { + if (searchQuery) { + // remove last search character + handleBackspace(); + return; + } + // No search query: start delete confirmation if session is selected + if (selectedSession && onDelete) { + setConfirmDeleteSessionId(selectedSession.id); + return; + } + } + + // Printable character: append to search query + if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab && !key.return) { + // Ignore if it's a named key that happens to have input (safety check) + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + return; + } + setSearchQuery((prev) => prev + input); + setIndex(0); + return; + } + + if (filteredSessions.length === 0) { + return; + } + if (key.upArrow) { setIndex((i) => Math.max(0, i - 1)); return; } if (key.downArrow) { - setIndex((i) => Math.min(sessions.length - 1, i + 1)); + setIndex((i) => Math.min(filteredSessions.length - 1, i + 1)); + return; + } + if (key.pageUp) { + setIndex((i) => Math.max(0, i - maxVisibleSessions)); + return; + } + if (key.pageDown) { + setIndex((i) => Math.min(filteredSessions.length - 1, i + maxVisibleSessions)); + return; + } + if (key.home) { + setIndex(0); + return; + } + if (key.end) { + setIndex(filteredSessions.length - 1); return; } if (key.return) { - const session = sessions[index]; + const session = filteredSessions[safeIndex]; if (session) { onSelect(session.id); } } }); + const hasActiveSearch = searchQuery.trim().length > 0; + if (sessions.length === 0) { return ( @@ -42,19 +187,116 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac } return ( - - Resume a session - {sessions.slice(0, 30).map((session, i) => ( - - {i === index ? "› " : " "} - {formatTimestamp(session.updateTime)} - {formatSessionTitle(session.summary || "Untitled")} - ({session.status}) - - ))} - {sessions.length > 30 ? … {sessions.length - 30} older sessions hidden. : null} - - ↑/↓ to navigate · Enter to select · Esc to cancel + + + {/* Header row */} + + + + Resume a session + + + {" "} + ({sessions.length} total + {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) + + + {/* Search bar */} + + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} + + + + {/* Session list */} + + {filteredSessions.length === 0 ? ( + + No sessions match "{searchQuery}". + + ) : ( + visibleSessions.map((session, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + const isConfirming = confirmDeleteSessionId === session.id; + return ( + + + {isSelected ? "> " : " "} + + + + + {formatSessionTitle(session.summary || "Untitled")} + + {isConfirming ? ( + [Delete? Enter=yes, Esc=no] + ) : ( + ({formatSessionStatus(session.status)}) + )} + + + {formatTimestamp(session.updateTime)} + + + + ); + }) + )} + {scrollOffset > 0 || scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + + {scrollOffset > 0 ? … {scrollOffset} sessions above. : null} + {scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + … {filteredSessions.length - scrollOffset - maxVisibleSessions} sessions below. + ) : null} + + ) : null} + + {/* Footer */} + + {confirmDeleteSessionId ? ( + + Delete this session? + + Enter + + to confirm · + + Esc + + to cancel + + ) : hasActiveSearch ? ( + + Esc clear search · + ↑/↓ navigate · Enter select · Esc again to cancel + + ) : ( + + + Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel · Del delete + + + )} + ); @@ -76,9 +318,25 @@ export function formatSessionTitle(value: string, max = 70): string { return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max); } -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; +export function formatSessionStatus(status: SessionStatus): string { + switch (status) { + case "completed": + return "done"; + case "processing": + return "running"; + case "pending": + return "pending"; + case "waiting_for_user": + return "waiting"; + case "failed": + return "failed"; + case "interrupted": + return "stopped"; + case "ask_permission": + return "waiting"; + case "permission_denied": + return "denied"; + default: + return status; } - return `${value.slice(0, max)}…`; } diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx new file mode 100644 index 0000000..df599b5 --- /dev/null +++ b/src/ui/SlashCommandMenu.tsx @@ -0,0 +1,83 @@ +import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; +import type { SlashCommandItem } from "./slashCommands"; +import { ARGS_SEPARATOR } from "./constants"; +import React from "react"; +import { Box, Text } from "ink"; +import type { SkillInfo } from "../session"; + +type SlashCommandMenuProps = { + items: SlashCommandItem[]; + activeIndex: number; + width: number; + maxVisible?: number; +}; +export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { + return skills.some((item) => item.name === skill.name); +} +const SlashCommandMenu = React.memo(function SlashCommandMenu({ + items, + activeIndex, + maxVisible = 6, + width, +}: SlashCommandMenuProps): React.ReactElement | null { + // 计算标签列最佳宽度:包含前缀"> "或" "(2字符),不超过容器一半(扣除gap) + const labelColumnWidth = React.useMemo(() => { + if (items.length === 0) { + return 0; + } + const longestLabel = Math.max( + ...items.map((s) => s.label.length + (s.args ? s.args?.join(ARGS_SEPARATOR)?.length + 4 : 0)) + ); + const contentWidth = longestLabel + 2; // +2 for prefix "> " or " " + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(contentWidth, maxAllowed); + }, [items, width]); + + if (items.length === 0) { + return null; + } + + // 计算可见窗口起始位置,确保 activeIndex 始终在可见区域内 + const visibleStart = Math.min( + Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), + Math.max(0, items.length - maxVisible) + ); + const visibleItems = items.slice(visibleStart, visibleStart + maxVisible); + + return ( + + {visibleStart > 0 ? ( + + + + ) : null} + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + return ( + + + + {actualIndex === activeIndex ? "> " : " "} + {formatSlashCommandLabel(item)} + + {item.args ? {item.args.join(ARGS_SEPARATOR)} : null} + + + + {formatSlashCommandDescription(item.description)} + + + + ); + })} + + {visibleStart + visibleItems.length < items.length ? : null} + + ({activeIndex + 1}/{items.length}) ↑↓ to navigate · Enter to select + + + + ); +}); + +export default SlashCommandMenu; diff --git a/src/ui/ThemedGradient.tsx b/src/ui/ThemedGradient.tsx new file mode 100644 index 0000000..f2c2369 --- /dev/null +++ b/src/ui/ThemedGradient.tsx @@ -0,0 +1,30 @@ +import type React from "react"; +import { Text, type TextProps } from "ink"; +import Gradient from "ink-gradient"; + +export const ThemedGradient: React.FC = ({ children, ...props }) => { + const gradient = ["#229ac3e6", "#229ac3e6"]; // Use solid color for now + + if (gradient && gradient.length >= 2) { + return ( + + {children} + + ); + } + + if (gradient && gradient.length === 1) { + return ( + + {children} + + ); + } + + // Fallback to accent color if no gradient + return ( + + {children} + + ); +}; diff --git a/src/ui/UndoSelector.tsx b/src/ui/UndoSelector.tsx new file mode 100644 index 0000000..fad3e17 --- /dev/null +++ b/src/ui/UndoSelector.tsx @@ -0,0 +1,195 @@ +import React, { useMemo, useState } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { UndoTarget } from "../session"; + +export type UndoRestoreMode = "code-and-conversation" | "conversation"; + +type Props = { + targets: UndoTarget[]; + onSelect: (target: UndoTarget, mode: UndoRestoreMode) => void; + onCancel: () => void; +}; + +type Phase = "message" | "mode"; + +const MAX_VISIBLE_TARGETS = 7; + +export function UndoSelector({ targets, onSelect, onCancel }: Props): React.ReactElement { + const [phase, setPhase] = useState("message"); + const [targetIndex, setTargetIndex] = useState(Math.max(0, targets.length - 1)); + const [modeIndex, setModeIndex] = useState(0); + const { columns, rows } = useWindowSize(); + + const safeTargetIndex = useMemo(() => { + if (targets.length === 0) { + return 0; + } + return Math.max(0, Math.min(targetIndex, targets.length - 1)); + }, [targetIndex, targets.length]); + + const selectedTarget = targets[safeTargetIndex] ?? null; + const maxVisible = Math.max(1, Math.min(MAX_VISIBLE_TARGETS, rows - 8)); + const scrollOffset = Math.max(0, Math.min(safeTargetIndex - Math.floor(maxVisible / 2), targets.length - maxVisible)); + const visibleTargets = targets.slice(scrollOffset, scrollOffset + maxVisible); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + if (phase === "mode") { + setPhase("message"); + return; + } + onCancel(); + return; + } + + if (targets.length === 0) { + return; + } + + if (phase === "message") { + if (key.upArrow) { + setTargetIndex((index) => Math.max(0, index - 1)); + return; + } + if (key.downArrow) { + setTargetIndex((index) => Math.min(targets.length - 1, index + 1)); + return; + } + if (key.home) { + setTargetIndex(0); + return; + } + if (key.end) { + setTargetIndex(targets.length - 1); + return; + } + if (key.return) { + setModeIndex(selectedTarget?.canRestoreCode ? 0 : 1); + setPhase("mode"); + } + return; + } + + if (key.upArrow || key.downArrow) { + setModeIndex((index) => (index === 0 ? 1 : 0)); + return; + } + if (key.return && selectedTarget) { + onSelect(selectedTarget, modeIndex === 0 ? "code-and-conversation" : "conversation"); + } + }); + + if (targets.length === 0) { + return ( + + Nothing to undo yet. + Press Esc to go back. + + ); + } + + return ( + + + + + Undo + + restore to the point before a prompt + + {phase === "message" ? ( + + {visibleTargets.map((target, visibleIndex) => { + const actualIndex = scrollOffset + visibleIndex; + const isActive = actualIndex === safeTargetIndex; + return ( + + {isActive ? "> " : " "} + + + {formatUndoMessage(target.message.content)} + + + {formatTimestamp(target.message.createTime)} + {target.canRestoreCode ? " · code checkpoint available" : " · conversation only"} + + + + ); + })} + + ) : ( + + Selected prompt: + {formatUndoMessage(selectedTarget?.message.content ?? "")} + + + {modeIndex === 0 ? "> " : " "}Restore code and conversation + + + {" "} + {selectedTarget?.canRestoreCode + ? "Restore files from the recorded Git checkpoint, then fork the conversation." + : "No code checkpoint is recorded for this prompt."} + + + {modeIndex === 1 ? "> " : " "}Restore conversation + + {" "}Fork the conversation without changing files. + + + )} + + + {phase === "message" + ? "↑/↓ navigate · Enter choose · Esc cancel" + : "↑/↓ choose restore mode · Enter restore · Esc back"} + + + + + ); +} + +function formatUndoMessage(content: unknown): string { + const text = typeof content === "string" && content.trim() ? content.trim() : "(empty message)"; + const singleLine = text.replace(/\r?\n/g, " ").replace(/\s+/g, " "); + return singleLine.length > 90 ? `${singleLine.slice(0, 89)}…` : singleLine; +} + +function formatTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) { + return value; + } + return date.toLocaleString(); +} diff --git a/src/ui/UpdatePrompt.tsx b/src/ui/UpdatePrompt.tsx index 93bd62b..f2b9e21 100644 --- a/src/ui/UpdatePrompt.tsx +++ b/src/ui/UpdatePrompt.tsx @@ -15,27 +15,22 @@ type Props = { onSelect: (choice: UpdatePromptChoice) => void; }; -export function UpdatePrompt({ - currentVersion, - latestVersion, - installCommand, - onSelect -}: Props): React.ReactElement { +export function UpdatePrompt({ currentVersion, latestVersion, installCommand, onSelect }: Props): React.ReactElement { const { exit } = useApp(); const [selectedIndex, setSelectedIndex] = useState(0); const options: UpdatePromptOption[] = [ { value: "install", - label: `Install the latest version with \`${installCommand}\`` + label: `Install the latest version with \`${installCommand}\``, }, { value: "ignore-once", - label: "Ignore once" + label: "Ignore once", }, { value: "ignore-version", - label: `Ignore this version (${latestVersion})` - } + label: `Ignore this version (${latestVersion})`, + }, ]; useInput((input, key) => { diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 077222b..7e740d1 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -1,25 +1,23 @@ import React, { useMemo, useState } from "react"; import { Box, Text } from "ink"; -import * as os from "os"; -import * as path from "path"; +import * as os from "node:os"; +import path from "node:path"; import type { SkillInfo } from "../session"; import type { ResolvedDeepcodingSettings } from "../settings"; -import { - BUILTIN_SLASH_COMMANDS, - buildSlashCommands, - formatSlashCommandDescription -} from "./slashCommands"; +import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "./slashCommands"; +import { ThemedGradient } from "./ThemedGradient"; +import { AsciiLogo } from "../AsciiArt"; +import { useAppContext } from "./contexts"; type WelcomeScreenProps = { projectRoot: string; settings: ResolvedDeepcodingSettings; skills: SkillInfo[]; - version: string; width: number; }; -const TITLE_PANEL_WIDTH = 30; -const PANEL_CONTENT_HEIGHT = 7; +const TITLE_PANEL_WIDTH = 70; +const PANEL_CONTENT_HEIGHT = 8; const SHORTCUT_TIPS = [ { label: "Enter", description: "Send the prompt" }, @@ -27,75 +25,59 @@ const SHORTCUT_TIPS = [ { label: "Ctrl+V", description: "Paste an image from the clipboard" }, { label: "Esc", description: "Interrupt the current model turn" }, { label: "/", description: "Open the skills and commands menu" }, - { label: "Ctrl+D twice", description: "Quit Deep Code CLI" } + { label: "Ctrl+D twice", description: "Quit Deep Code CLI" }, ]; -export function WelcomeScreen({ - projectRoot, - settings, - skills, - version, - width -}: WelcomeScreenProps): React.ReactElement { +export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeScreenProps): React.ReactElement { + const { version } = useAppContext(); const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); const compact = width < TITLE_PANEL_WIDTH + 42; const cwd = formatHomeRelativePath(projectRoot); const tip = tips[Math.min(tipIndex, Math.max(0, tips.length - 1))] ?? tips[0]; - const panelWidth = compact ? undefined : Math.min(width, 92); + const panelWidth = compact ? undefined : Math.min(width, 72); return ( - - - - + + + + - - Deep Code - - (v{version || "unknown"}) + {AsciiLogo} - {!compact ? ( - - {Array.from({ length: PANEL_CONTENT_HEIGHT }, (_, index) => ( - - │ - - ))} - - ) : null} - + + {">"}_ Deep Code + (v{version || "unknown"}) + {!compact ? : null} - - - - - {!compact ? : null} - {!compact ? : null} + + + + - {tip ? ( - - - Tips: {tip.label} - {tip.description} - - - ) : null} + + {tip ? ( + + + Tips: {tip.label} - {tip.description} + + + ) : null} + ); } @@ -132,12 +114,12 @@ export function buildWelcomeTips(skills: SkillInfo[]): Array<{ label: string; de .filter((item) => item.kind !== "skill" || item.skill?.isLoaded) .map((item) => ({ label: item.label, - description: formatSlashCommandDescription(item.description) + description: formatSlashCommandDescription(item.description), })); return [ ...slashTips, - ...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)) + ...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)), ]; } diff --git a/src/ui/askUserQuestion.ts b/src/ui/askUserQuestion.ts index f3e7e6c..8d168d8 100644 --- a/src/ui/askUserQuestion.ts +++ b/src/ui/askUserQuestion.ts @@ -39,7 +39,7 @@ export function findPendingAskUserQuestion( return { messageId: message.id, sessionId: message.sessionId, - questions + questions, }; } @@ -87,9 +87,10 @@ function normalizeQuestions(raw: unknown): AskUserQuestionItem[] { if (!item || typeof item !== "object" || Array.isArray(item)) { continue; } - const question = typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; + const question = + typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; const rawOptions = (item as { options?: unknown }).options; if (!question || !Array.isArray(rawOptions) || rawOptions.length === 0) { continue; @@ -100,9 +101,10 @@ function normalizeQuestions(raw: unknown): AskUserQuestionItem[] { if (options.length === 0) { continue; } - const multiSelect = typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" - ? (item as { multiSelect: boolean }).multiSelect - : undefined; + const multiSelect = + typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" + ? (item as { multiSelect: boolean }).multiSelect + : undefined; questions.push({ question, multiSelect, options }); } return questions; @@ -112,21 +114,20 @@ function normalizeOption(raw: unknown): AskUserQuestionOption | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return null; } - const label = typeof (raw as { label?: unknown }).label === "string" - ? (raw as { label: string }).label.trim() - : ""; + const label = typeof (raw as { label?: unknown }).label === "string" ? (raw as { label: string }).label.trim() : ""; if (!label) { return null; } - const description = typeof (raw as { description?: unknown }).description === "string" - ? (raw as { description: string }).description.trim() - : ""; + const description = + typeof (raw as { description?: unknown }).description === "string" + ? (raw as { description: string }).description.trim() + : ""; return { label, - description: description || undefined + description: description || undefined, }; } function escapeAnswerPart(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\s+/g, " ").trim(); + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim(); } diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts index 72ecd59..c9e30e9 100644 --- a/src/ui/clipboard.ts +++ b/src/ui/clipboard.ts @@ -14,7 +14,7 @@ const IMAGE_MIME_BY_EXT = new Map([ [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".gif", "image/gif"], - [".webp", "image/webp"] + [".webp", "image/webp"], ]); function bufferToDataUrl(buffer: Buffer, mimeType: string): string { @@ -83,7 +83,7 @@ function readMacClipboardImage(): ClipboardImage | null { "-e", "write png_data to fp", "-e", - "close access fp" + "close access fp", ]); if (saved) { @@ -142,3 +142,17 @@ export function readClipboardImage(): ClipboardImage | null { return null; } + +export async function readClipboardImageAsync(): Promise { + return new Promise((resolve, reject) => { + // Use setImmediate to avoid blocking the event loop + setImmediate(() => { + try { + const result = readClipboardImage(); + resolve(result); + } catch (error) { + reject(error); + } + }); + }); +} diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx new file mode 100644 index 0000000..ce9a8ee --- /dev/null +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { FileMentionItem, FileMentionToken } from "../../fileMentions"; + +type Props = { + open: boolean; + width: number; + token: FileMentionToken | null; + items: FileMentionItem[]; + onClose: () => void; + onSelect: (item: FileMentionItem) => void; +}; + +const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, onSelect }) => { + const [activeIndex, setActiveIndex] = useState(0); + + // Reset index when opened + useEffect(() => { + if (open) { + setActiveIndex(0); + } + }, [open]); + + // Validate activeIndex bounds + useEffect(() => { + if (!open) { + return; + } + if (items.length === 0) { + setActiveIndex(0); + return; + } + if (activeIndex >= items.length) { + setActiveIndex(Math.max(0, items.length - 1)); + } + }, [activeIndex, items.length, open]); + + useInput( + (input, key) => { + if (!open) { + return; + } + + if (key.escape) { + onClose(); + return; + } + + if (key.upArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx - 1 + items.length) % items.length); + } + return; + } + + if (key.downArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx + 1) % items.length); + } + return; + } + + if (key.tab || (key.return && !key.shift && !key.meta)) { + const selected = items[activeIndex]; + if (selected) { + onSelect(selected); + return; + } + if (key.tab) { + onClose(); + } + return; + } + }, + { isActive: open } + ); + + if (!open) { + return null; + } + + return ( + ({ + key: item.path, + label: item.path, + description: item.type === "directory" ? "directory" : "file", + }))} + activeIndex={activeIndex} + activeColor="#229ac3" + maxVisible={8} + renderItem={(item, isActive) => ( + + {isActive ? "> " : " "} + + + {item.label} + + + {item.description ? ( + + {item.description} + + ) : null} + + )} + /> + ); +}; + +export default FileMentionMenu; diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx new file mode 100644 index 0000000..9c31551 --- /dev/null +++ b/src/ui/components/MessageView/index.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { renderMarkdown, renderMarkdownSegments } from "./markdown"; +import { + buildThinkingSummary, + buildToolSummary, + formatStatusName, + formatToolStatusParams, + getToolDiffPreviewLines, + getUpdatePlanPreviewLines, +} from "./utils"; +import type { DiffPreviewLine, MessageViewProps } from "./types"; +import { RawMode, useRawModeContext } from "../../contexts"; + +export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null { + const { mode } = useRawModeContext(); + if (!message.visible) { + return null; + } + + if (message.role === "user") { + const text = message.content || "(no content)"; + return ( + + + {`>`} + + + {text} + {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( + {` 📎 ${message.contentParams.length} image attachment(s)`} + ) : null} + + + ); + } + + if (message.role === "assistant") { + const isThinking = Boolean(message.meta?.asThinking); + const content = (message.content || "").trim(); + + if (isThinking) { + const summary = buildThinkingSummary(content, message.messageParams, mode); + if (collapsed !== false) { + return ( + + + + ); + } + return ( + + + + {content ? {renderMarkdown(content)} : null} + + + ); + } + + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + + return ( + + + + + + {content + ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { + if (seg.kind === "table") { + return ( + + {seg.body.split("\n").map((line, lineIndex) => ( + + {line} + + ))} + + ); + } + return {seg.body}; + }) + : null} + + + ); + } + + if (message.role === "tool") { + const summary = buildToolSummary(message); + const diffLines = getToolDiffPreviewLines(summary); + const planLines = getUpdatePlanPreviewLines(summary); + return ( + + + {diffLines.length > 0 ? : null} + {planLines.length > 0 ? : null} + + ); + } + + if (message.role === "system") { + // Render model change messages in the same style as user commands. + if (message.meta?.isModelChange) { + return ( + + + {`>`} + + + {message.content} + + + ); + } + + if (message.meta?.skill) { + return ( + + ⚡ Loaded skill: {message.meta.skill.name} + + ); + } + if (message.meta?.isSummary) { + return ( + + + (conversation summary inserted) + + + ); + } + return null; + } + + return null; +} + +function StatusLine({ + bulletColor, + name, + params, + width, +}: { + bulletColor: "gray" | "green" | "red"; + name: string; + params: string; + width: number; +}): React.ReactElement { + const { mode } = useRawModeContext(); + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + return ( + + + + ✧ + + + + + + {name} + + {params ? ( + + {` ${params}`} + + ) : null} + + + + ); +} + +function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { + return ( + + └ Changes + + {lines.map((line, index) => ( + + + {line.marker} + + + {line.content} + + + ))} + + + ); +} + +function PlanPreview({ lines }: { lines: string[] }): React.ReactElement { + return ( + + └ Plan + + {lines.map((line, index) => ( + + {line} + + ))} + + + ); +} diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts new file mode 100644 index 0000000..3ebb58b --- /dev/null +++ b/src/ui/components/MessageView/markdown.ts @@ -0,0 +1,405 @@ +import chalk from "chalk"; + +/** + * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for + * `table` segments and the default wrap mode for `text` segments so that Ink + * never breaks box-drawing lines at cell boundary spaces. + */ +export type MarkdownSegment = + | { kind: "text"; body: string } + | { kind: "table"; body: string } + | { kind: "code"; body: string; lang: string }; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Render markdown to a single string (backward-compatible). */ +export function renderMarkdown(text: string, maxWidth?: number): string { + return renderMarkdownSegments(text, maxWidth) + .map((s) => s.body) + .reduce((out, body) => { + if (!out) return body; + if (!body) return out; + return out.endsWith("\n") || body.startsWith("\n") ? out + body : `${out}\n${body}`; + }, ""); +} + +/** Render markdown, returning typed segments so the caller can choose the + right `` per segment. */ +export function renderMarkdownSegments(text: string, maxWidth?: number): MarkdownSegment[] { + if (!text) return []; + + const segments: MarkdownSegment[] = []; + const fenceSegments = splitByFences(text); + + for (const seg of fenceSegments) { + if (seg.kind === "code") { + const langTag = seg.lang ? chalk.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + chalk.cyan(seg.body), lang: seg.lang }); + continue; + } + const blocks = splitTableBlocks(seg.body); + for (const b of blocks) { + if (b.kind === "table") { + segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth) }); + } else { + const body = b.body + .split("\n") + .map((line) => renderInlineLine(line)) + .join("\n"); + if (body) segments.push({ kind: "text", body }); + } + } + } + + return segments; +} + +// --------------------------------------------------------------------------- +// Code fences +// --------------------------------------------------------------------------- + +type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; + +function splitByFences(text: string): FenceSegment[] { + const segments: FenceSegment[] = []; + const lines = text.split(/\r?\n/); + let buffer: string[] = []; + let inFence = false; + let fenceLang = ""; + let fenceBody: string[] = []; + + const flushText = () => { + if (buffer.length > 0) { + segments.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; + } + }; + + for (const line of lines) { + const m = /^\s*```(\w*)\s*$/.exec(line); + if (m) { + if (!inFence) { + flushText(); + inFence = true; + fenceLang = m[1] ?? ""; + fenceBody = []; + } else { + segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); + inFence = false; + } + continue; + } + (inFence ? fenceBody : buffer).push(line); + } + + if (inFence) { + segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); + } else { + flushText(); + } + + return segments; +} + +// --------------------------------------------------------------------------- +// Table parsing +// --------------------------------------------------------------------------- + +type TableBlock = { kind: "text"; body: string } | { kind: "table"; rows: string[][] }; + +function splitTableBlocks(text: string): TableBlock[] { + const lines = text.split(/\r?\n/); + const blocks: TableBlock[] = []; + let buffer: string[] = []; + let tableRows: string[][] = []; + let inTable = false; + + const flushText = () => { + if (buffer.length > 0) { + blocks.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; + } + }; + const flushTable = () => { + if (tableRows.length >= 2) { + blocks.push({ kind: "table", rows: tableRows }); + } else if (tableRows.length > 0) { + buffer.push(...tableRows.map((r) => r.join(" | "))); + } + tableRows = []; + }; + + const sepRe = /^\|?\s*:?[-]{3,}:?\s*(\|\s*:?[-]{3,}:?\s*)*\|?\s*$/; + const parseRow = (row: string) => { + let body = row.trim(); + if (body.startsWith("|")) body = body.slice(1); + if (body.endsWith("|")) body = body.slice(0, -1); + return body.split("|").map((s) => s.trim()); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const nextTrimmed = (lines[i + 1] ?? "").trim(); + + // skip separator line + if (inTable && sepRe.test(trimmed) && tableRows.length === 1) continue; + + const isRow = /^\|.+\|$/.test(trimmed); + const isHeader = isRow && i + 1 < lines.length && sepRe.test(nextTrimmed); + + if (isHeader && !inTable) { + flushText(); + inTable = true; + tableRows = [parseRow(trimmed)]; + continue; + } + + if (isRow && inTable) { + tableRows.push(parseRow(trimmed)); + continue; + } + + if (inTable && !isRow) { + flushTable(); + inTable = false; + } + buffer.push(line); + } + + return inTable ? [...blocks, ...flushTableResult(tableRows)] : [...blocks, ...flushTextOnly(buffer, tableRows)]; +} + +function flushTableResult(rows: string[][]): TableBlock[] { + if (rows.length >= 2) return [{ kind: "table", rows }]; + if (rows.length > 0) return [{ kind: "text", body: rows.map((r) => r.join(" | ")).join("\n") }]; + return []; +} + +function flushTextOnly(buffer: string[], tableRows: string[][]): TableBlock[] { + const result: TableBlock[] = []; + if (buffer.length > 0) result.push({ kind: "text", body: buffer.join("\n") }); + if (tableRows.length >= 2) result.push({ kind: "table", rows: tableRows }); + else if (tableRows.length > 0) result.push({ kind: "text", body: tableRows.map((r) => r.join(" | ")).join("\n") }); + return result; +} + +// --------------------------------------------------------------------------- +// Terminal visual width (CJK / emoji = 2 cols, ASCII = 1) +// --------------------------------------------------------------------------- + +function visualWidth(text: string): number { + let w = 0; + for (const ch of text) { + if (ch.length >= 2) { + w += 2; + continue; + } + const code = ch.codePointAt(0) ?? ch.charCodeAt(0); + w += isWideChar(code) ? 2 : 1; + } + return w; +} + +function isWideChar(code: number): boolean { + return ( + (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo + (code >= 0x2329 && code <= 0x232a) || // Misc technical + (code >= 0x2e80 && code <= 0xa4cf) || // CJK Radicals, Kangxi, CJK all + (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compat + (code >= 0xfe10 && code <= 0xfe6f) || // CJK Compat Forms + (code >= 0xff00 && code <= 0xffe6) || // Fullwidth + (code >= 0x20000 && code <= 0x3fffd) || // CJK Ext B+ + (code >= 0x1f300 && code <= 0x1faff) || // Emoji & pictographs + (code >= 0x2600 && code <= 0x27bf) || // Misc Symbols + (code >= 0x2300 && code <= 0x23ff) || // Misc Technical + (code >= 0x2b00 && code <= 0x2bff) || // Misc Symbols & Arrows + (code >= 0x1f000 && code <= 0x1f02f) // Mahjong & Domino + ); +} + +// --------------------------------------------------------------------------- +// Table rendering +// --------------------------------------------------------------------------- + +function renderTableBorder(rows: string[][], maxWidth?: number): string { + if (rows.length === 0) return ""; + + const colCount = rows[0].length; + const normalizedRows = rows.map((row) => + Array.from({ length: colCount }, (_, i) => { + return row[i] ?? ""; + }) + ); + const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; + + // Natural width per column, measured as terminal cells rather than UTF-16 units. + const natural: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = normalizedRows.map((r) => r[i] ?? ""); + const maxLine = Math.max(4, ...texts.map((t) => visualWidth(t))); + return maxLine; + }); + + // Keep minimums small so long CJK text or unbroken tokens can wrap by character. + const minWidths: number[] = Array.from({ length: colCount }, (_, i) => { + const headerWidth = visualWidth(normalizedRows[0]?.[i] ?? ""); + const labelColumn = natural[i] <= 12; + const minReadable = labelColumn ? natural[i] : Math.max(4, Math.min(headerWidth, 12)); + return Math.min(natural[i], minReadable); + }); + + let colWidths: number[]; + const totalNatural = calcW(natural); + const totalMin = calcW(minWidths); + + const effectiveMax = maxWidth ?? 120; // default to a generous terminal width + + if (totalNatural <= effectiveMax) { + // Content fits comfortably — use natural widths and grow to fill available space + colWidths = [...natural]; + const slack = effectiveMax - totalNatural; + if (slack > 0) { + // Distribute slack proportionally to content columns (skip tiny label columns) + const isLabel = colWidths.map((w) => w <= 8); + const candidates = colWidths.map((w, i) => (isLabel[i] ? 0 : w)); + const totalWeight = candidates.reduce((a, b) => a + b, 0); + if (totalWeight > 0) { + for (let ci = 0; ci < colCount; ci++) { + if (candidates[ci] > 0) { + colWidths[ci] += Math.floor((slack * candidates[ci]) / totalWeight); + } + } + } + } + } else if (totalMin >= effectiveMax) { + colWidths = [...minWidths]; + while (calcW(colWidths) > effectiveMax && colWidths.some((w) => w > 1)) { + const widest = colWidths.reduce((maxIdx, width, idx) => (width > colWidths[maxIdx] ? idx : maxIdx), 0); + colWidths[widest]--; + } + } else { + // Need to compress — start from mins, share remaining budget proportionally + const budget = effectiveMax - totalMin; + const deficits = natural.map((n, i) => Math.max(0, n - minWidths[i])); + const totalDeficit = deficits.reduce((a, b) => a + b, 0); + colWidths = [...minWidths]; + if (totalDeficit > 0) { + for (let ci = 0; ci < colCount; ci++) { + colWidths[ci] += Math.floor((budget * deficits[ci]) / totalDeficit); + } + } + // Distribute any leftover due to flooring + let used = calcW(colWidths); + const deficitByIdx = colWidths.map((w, i) => ({ i, gap: natural[i] - w })); + deficitByIdx.sort((a, b) => b.gap - a.gap); + for (const { i } of deficitByIdx) { + if (used >= effectiveMax) break; + if (colWidths[i] < natural[i]) { + colWidths[i]++; + used = calcW(colWidths); + } + } + } + + // Word-wrap a single cell + const wrapCell = (text: string, width: number): string[] => { + if (!text) return [""]; + const lines: string[] = []; + let cur = ""; + const flush = () => { + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + cur = ""; + }; + + for (const ch of text) { + const cw = visualWidth(ch); + if (visualWidth(cur) + cw > width) { + const lastSpace = cur.lastIndexOf(" "); + if (lastSpace > width / 3) { + const carry = cur.slice(lastSpace + 1); + cur = cur.slice(0, lastSpace); + flush(); + cur = carry + ch; + } else { + flush(); + cur = ch; + } + } else { + cur += ch; + } + } + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + return lines.length > 0 ? lines : [""]; + }; + + const wrapped = normalizedRows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); + const heights = wrapped.map((wr) => Math.max(1, ...wr.map((lines) => lines.length))); + + const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); + + const top = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐"; + const hdr = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const sep = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const bot = "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘"; + + const out: string[] = [top]; + + for (let ri = 0; ri < wrapped.length; ri++) { + const h = heights[ri]; + for (let li = 0; li < h; li++) { + const line = wrapped[ri].map((cellLines, ci) => " " + pad(cellLines[li] ?? "", colWidths[ci]) + " "); + out.push("│" + line.join("│") + "│"); + } + if (ri === 0 && rows.length > 1) out.push(hdr); + else if (ri < rows.length - 1) out.push(sep); + } + + out.push(bot); + return out.join("\n"); +} + +// --------------------------------------------------------------------------- +// Inline formatting (headings, lists, quotes, bold/italic/code) +// --------------------------------------------------------------------------- + +function renderInlineLine(line: string): string { + const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); + if (headingMatch) { + const [, lead, hashes, content] = headingMatch; + const styled = hashes.length <= 2 ? chalk.bold.cyanBright(content) : chalk.bold.cyan(content); + return `${lead}${chalk.dim(hashes)} ${styled}`; + } + + const listMatch = /^(\s*)([-*+])\s+(.*)$/.exec(line); + if (listMatch) { + const [, lead, bullet, content] = listMatch; + return `${lead}${chalk.yellow(bullet)} ${renderInlineSpans(content)}`; + } + + const numListMatch = /^(\s*)(\d+\.)\s+(.*)$/.exec(line); + if (numListMatch) { + const [, lead, marker, content] = numListMatch; + return `${lead}${chalk.yellow(marker)} ${renderInlineSpans(content)}`; + } + + const quoteMatch = /^(\s*)>\s?(.*)$/.exec(line); + if (quoteMatch) { + const [, lead, content] = quoteMatch; + return `${lead}${chalk.dim("│ ")}${chalk.italic(renderInlineSpans(content))}`; + } + + return renderInlineSpans(line); +} + +function renderInlineSpans(text: string): string { + if (!text) return text; + let result = text; + result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); + result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); + result = result.replace(/(? chalk.italic(inner)); + result = result.replace(/_([^_\n]+)_/g, (_, inner) => chalk.italic(inner)); + return result; +} diff --git a/src/ui/components/MessageView/types.ts b/src/ui/components/MessageView/types.ts new file mode 100644 index 0000000..743eb2d --- /dev/null +++ b/src/ui/components/MessageView/types.ts @@ -0,0 +1,19 @@ +import type { SessionMessage } from "../../../session"; + +export type MessageViewProps = { + message: SessionMessage; + collapsed?: boolean; + width?: number; +}; +export type ToolSummary = { + name: string; + params: string; + ok: boolean; + metadata: Record | null; +}; + +export type DiffPreviewLine = { + marker: string; + content: string; + kind: "added" | "removed" | "context"; +}; diff --git a/src/ui/components/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts new file mode 100644 index 0000000..af5391d --- /dev/null +++ b/src/ui/components/MessageView/utils.ts @@ -0,0 +1,286 @@ +import type { DiffPreviewLine, ToolSummary } from "./types"; +import type { SessionMessage } from "../../../session"; +import { RawMode } from "../../contexts"; +import chalk from "chalk"; + +/** Type guard that checks whether a value is a plain object (not null, not an array). */ +export function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +/** Capitalizes the first character of a tool status name, falling back to "Tool". */ +export function formatStatusName(value: string): string { + return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; +} + +/** Truncates a string to the given maximum length, appending an ellipsis when truncated. */ +export function truncate(value: string, max: number): string { + if (value.length <= max) { + return value; + } + return `${value.slice(0, max)}…`; +} + +/** Returns the first non-empty line from a multi-line string, normalizing whitespace. */ +export function firstNonEmptyLine(value: string): string { + for (const line of value.split(/\r?\n/)) { + const trimmed = line.trim().replace(/\s+/g, " "); + if (trimmed) { + return trimmed; + } + } + return ""; +} + +/** + * Builds a one-line summary of thinking / reasoning content. + * Falls back to "(reasoning...)" when only reasoning_content params are present. + */ +export function buildThinkingSummary(content: string, messageParams: unknown | null, mode?: RawMode): string { + if (content) { + const normalized = content.replace(/\r?\n/g, " ").replace(/\s+/g, " "); + let result = truncate(normalized, 100); + if (result.endsWith(":") || result.endsWith(":")) { + result = result.slice(0, -1); + } + return result; + } + + const params = messageParams as { reasoning_content?: unknown } | null | undefined; + if (typeof params?.reasoning_content === "string" && params.reasoning_content.trim()) { + return mode !== RawMode.Lite ? params?.reasoning_content || "" : "(reasoning...)"; + } + + return ""; +} + +/** Formats a tool's parameters for status display, preserving full bash commands but truncating others. */ +export function formatToolStatusParams(summary: ToolSummary): string { + const params = firstNonEmptyLine(summary.params); + return summary.name.toLowerCase() === "bash" ? params : truncate(params, 120); +} + +/** Builds a structured summary (name, params, ok, metadata) from a tool session message. */ +export function buildToolSummary(message: SessionMessage): ToolSummary { + const payload = parseToolPayload(message.content); + const metaFunctionName = + message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" + ? (message.meta.function as { name: string }).name + : null; + const name = payload.name || metaFunctionName || "tool"; + const params = + name === "AskUserQuestion" + ? extractAskUserQuestionParams(message) || getMetaParams(message) + : getMetaParams(message); + + return { + name, + params, + ok: payload.ok !== false, + metadata: payload.metadata, + }; +} + +/** Extracts the paramsMd field from a session message's metadata, trimmed. */ +export function getMetaParams(message: SessionMessage): string { + return typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; +} + +/** + * Extracts human-readable question text from an AskUserQuestion tool message. + * Tries the tool function arguments first, then falls back to parsing metadata params. + */ +export function extractAskUserQuestionParams(message: SessionMessage): string { + const fromFunction = extractQuestionsFromToolFunction(message.meta?.function); + if (fromFunction) { + return fromFunction; + } + + const params = getMetaParams(message); + if (!params) { + return ""; + } + + try { + const parsed = JSON.parse(params); + return extractQuestionsFromValue(parsed); + } catch { + return ""; + } +} + +/** + * Extracts question strings from a tool function object by parsing its JSON arguments. + */ +export function extractQuestionsFromToolFunction(toolFunction: unknown): string { + if (!toolFunction || typeof toolFunction !== "object") { + return ""; + } + const args = (toolFunction as { arguments?: unknown }).arguments; + if (typeof args !== "string" || !args.trim()) { + return ""; + } + try { + const parsed = JSON.parse(args); + return extractQuestionsFromValue((parsed as { questions?: unknown })?.questions); + } catch { + return ""; + } +} + +/** Extracts and joins question strings from an array of question objects. */ +export function extractQuestionsFromValue(value: unknown): string { + if (!Array.isArray(value)) { + return ""; + } + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return ""; + } + return typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; + }) + .filter(Boolean) + .join(" / "); +} + +/** Parses a tool's JSON payload, extracting name, ok flag, and metadata. */ +export function parseToolPayload(content: string | null): { + name: string | null; + ok: boolean; + metadata: Record | null; +} { + if (!content) { + return { name: null, ok: true, metadata: null }; + } + + try { + const parsed = JSON.parse(content) as { name?: unknown; ok?: unknown; metadata?: unknown }; + return { + name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, + ok: parsed.ok !== false, + metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, + }; + } catch { + return { name: null, ok: true, metadata: null }; + } +} + +/** + * Returns structured diff preview lines for successful edit or write tool calls. + * Returns an empty array if the tool is not edit/write or has no diff_preview metadata. + */ +export function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { + if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { + return []; + } + const diffPreview = summary.metadata?.diff_preview; + if (typeof diffPreview !== "string" || !diffPreview.trim()) { + return []; + } + return parseDiffPreview(diffPreview); +} + +/** Parses a unified-diff-style preview string into an array of structured diff lines. */ +export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { + return diffPreview + .split("\n") + .filter((line) => line && !line.startsWith("--- ") && !line.startsWith("+++ ") && !line.startsWith("@@ ")) + .map((line) => { + if (line.startsWith("+")) { + return { marker: "+", content: line.slice(1), kind: "added" }; + } + if (line.startsWith("-")) { + return { marker: "-", content: line.slice(1), kind: "removed" }; + } + return { + marker: " ", + content: line.startsWith(" ") ? line.slice(1) : line, + kind: "context", + }; + }); +} + +export function renderMessageToStdout(message: SessionMessage, mode: RawMode): string { + if (!message.visible) { + return ""; + } + + if (message.role === "user") { + const text = message.content || "(no content)"; + return chalk(`> ${text}`); + } + + if (message.role === "assistant") { + const isThinking = Boolean(message.meta?.asThinking); + const content = (message.content || "").trim(); + + if (isThinking) { + const summary = buildThinkingSummary(content, message.messageParams, mode); + return `${chalk("✧")} ${chalk("Thinking")}${summary ? ` ${chalk(summary)}` : ""}`; + } + + return `${chalk("✦")} ${content}`; + } + + if (message.role === "tool") { + const payload = parseToolPayload(message.content); + const metaFunctionName = + message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" + ? (message.meta.function as { name: string }).name + : null; + const name = payload.name || metaFunctionName || "tool"; + const metaParams = typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; + const params = name.toLowerCase() === "bash" ? metaParams : truncate(metaParams, 120); + const statusLine = `${chalk("✧")} ${chalk(formatStatusName(name))}${params ? ` ${chalk(params)}` : ""}`; + + const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : ""; + const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : ""; + + const summary: ToolSummary = { + name, + params, + ok: payload.ok !== false, + metadata: payload.metadata, + }; + const planLines = getUpdatePlanPreviewLines(summary); + if (planLines.length > 0) { + const planText = planLines.map((line) => ` ${line}`).join("\n"); + return `${statusLine}\n${chalk.dim(" └ Plan")}\n${planText}${result}`; + } + + return `${statusLine}${result}`; + } + + if (message.role === "system") { + if (message.meta?.isModelChange) { + return chalk(`> ${message.content}`); + } + if (message.meta?.skill && typeof message.meta.skill === "object") { + const skillName = (message.meta.skill as { name?: unknown }).name; + return chalk(`⚡ Loaded skill: ${typeof skillName === "string" ? skillName : ""}`); + } + if (message.meta?.isSummary) { + return chalk.dim.italic("(conversation summary inserted)"); + } + return ""; + } + + return ""; +} + +export function getUpdatePlanPreviewLines(summary: ToolSummary): string[] { + if (!summary.ok || summary.name !== "UpdatePlan") { + return []; + } + const plan = summary.metadata?.plan; + if (typeof plan !== "string" || !plan.trim()) { + return []; + } + return plan + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); +} diff --git a/src/ui/components/ModelsDropdown/index.tsx b/src/ui/components/ModelsDropdown/index.tsx new file mode 100644 index 0000000..bdd68ab --- /dev/null +++ b/src/ui/components/ModelsDropdown/index.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { ModelConfigSelection, ReasoningEffort } from "../../../settings"; + +type ModelStep = "model" | "thinking"; + +type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; + +function getThinkingOptionIndex(config: Pick): number { + const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { + if (!config.thinkingEnabled) { + return !option.thinkingEnabled; + } + return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; + }); + return index >= 0 ? index : 0; +} + +type Props = { + open: boolean; + modelConfig: ModelConfigSelection; + width: number; + onClose: () => void; + onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onStatusMessage?: (message: string | null) => void; +}; + +const ModelsDropdown: React.FC = ({ + open, + modelConfig, + width, + onClose, + onModelConfigChange, + onStatusMessage, +}) => { + const [step, setStep] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [pendingModel, setPendingModel] = useState(null); + + // Initialize state when opened + useEffect(() => { + if (open) { + const currentIndex = MODEL_COMMAND_MODELS.findIndex((m) => m === modelConfig.model); + setPendingModel(null); + setStep("model"); + setActiveIndex(currentIndex >= 0 ? currentIndex : 0); + } else { + setStep(null); + } + }, [open, modelConfig.model]); + + // Validate activeIndex bounds + useEffect(() => { + if (!step) { + return; + } + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + if (activeIndex >= optionCount) { + setActiveIndex(Math.max(0, optionCount - 1)); + } + }, [activeIndex, step]); + + function selectItem(): void { + if (step === "model") { + const model = MODEL_COMMAND_MODELS[activeIndex] ?? modelConfig.model; + setPendingModel(model); + setStep("thinking"); + setActiveIndex(getThinkingOptionIndex(modelConfig)); + return; + } + + const option = MODEL_COMMAND_THINKING_OPTIONS[activeIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]!; + const selection: ModelConfigSelection = { + model: pendingModel ?? modelConfig.model, + thinkingEnabled: option.thinkingEnabled, + reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, + }; + onClose(); + Promise.resolve(onModelConfigChange(selection)) + .then((message) => { + if (message) { + onStatusMessage?.(message); + } + }) + .catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + onStatusMessage?.(`Failed to update model settings: ${msg}`); + }); + } + + useInput( + (input, key) => { + if (!step) { + return; + } + + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + + if (key.upArrow) { + setActiveIndex((idx) => (idx - 1 + optionCount) % optionCount); + return; + } + if (key.downArrow) { + setActiveIndex((idx) => (idx + 1) % optionCount); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + selectItem(); + return; + } + if (key.tab || key.escape) { + onClose(); + return; + } + }, + { isActive: open } + ); + + if (!open || !step) { + return null; + } + + const items = + step === "model" + ? MODEL_COMMAND_MODELS.map((model) => ({ + key: model, + label: model, + description: model === modelConfig.model ? "current model" : "", + selected: model === (pendingModel ?? modelConfig.model), + })) + : MODEL_COMMAND_THINKING_OPTIONS.map((option, i) => ({ + key: option.label, + label: option.label, + description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", + selected: getThinkingOptionIndex(modelConfig) === i, + })); + + return ( + + ); +}; + +export { getThinkingOptionIndex }; +export default ModelsDropdown; diff --git a/src/ui/components/RawModeExitPrompt/index.tsx b/src/ui/components/RawModeExitPrompt/index.tsx new file mode 100644 index 0000000..57ebf07 --- /dev/null +++ b/src/ui/components/RawModeExitPrompt/index.tsx @@ -0,0 +1,20 @@ +import { useRef, type ReactElement } from "react"; +import { useInput } from "ink"; +import { useRawModeContext, type RawMode } from "../../contexts"; + +export function RawModeExitPrompt({ onExit }: { onExit: (previousMode: RawMode) => void }): ReactElement | null { + const { previousMode } = useRawModeContext(); + // Snapshot the prior mode at mount so later context updates do not change the ESC target. + const snapshotRef = useRef(previousMode); + + useInput( + (_input, key) => { + if (key.escape) { + onExit(snapshotRef.current); + } + }, + { isActive: true } + ); + + return null; +} diff --git a/src/ui/components/RawModelDropdown/index.tsx b/src/ui/components/RawModelDropdown/index.tsx new file mode 100644 index 0000000..3397013 --- /dev/null +++ b/src/ui/components/RawModelDropdown/index.tsx @@ -0,0 +1,55 @@ +import React, { useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../../DropdownMenu"; +import type { RawMode } from "../../contexts"; +import { RAW_COMMAND_MODELS, useRawModeContext } from "../../contexts"; + +const RawModelDropdown: React.FC<{ + open: boolean; + screenWidth: number; + onClose?: (value: boolean) => void; + onSelect?: (model: string) => void; +}> = ({ open = false, screenWidth, onSelect, onClose }) => { + const { mode, setMode } = useRawModeContext(); + const [index, setIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => Math.min(RAW_COMMAND_MODELS.length - 1, i + 1)); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + setMode(RAW_COMMAND_MODELS[index].key as RawMode); + onClose?.(false); + onSelect?.(RAW_COMMAND_MODELS[index].key); + return; + } + if (key.escape) { + onClose?.(false); + return; + } + }, + { isActive: open } + ); + if (!open) { + return null; + } + return ( + ({ ...model, selected: model.key === mode }))} + helpText="Space/Enter select mode · Esc to close" + // onSelect={onSelect} + activeColor="#229ac3" + maxVisible={6} + activeIndex={index} + width={screenWidth} + /> + ); +}; + +export default RawModelDropdown; diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx new file mode 100644 index 0000000..b320d24 --- /dev/null +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -0,0 +1,74 @@ +import DropdownMenu from "../../DropdownMenu"; +import React, { useEffect, useState } from "react"; +import type { SkillInfo } from "../../../session"; +import { useInput } from "ink"; +import { isSkillSelected } from "../../SlashCommandMenu"; + +const SkillsDropdown: React.FC<{ + open: boolean; + onClose?: (value: boolean) => void; + width: number; + skills: SkillInfo[]; + selectedSkills: SkillInfo[]; + onSelect?: (skill: SkillInfo) => void; +}> = ({ open, width, skills, selectedSkills, onSelect, onClose }) => { + const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); + return; + } + if (key.downArrow) { + setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + const skill = skills[skillsDropdownIndex]; + if (skill) { + onSelect?.(skill); + } + return; + } + if (key.tab) { + onClose?.(false); + return; + } + if (key.escape) { + onClose?.(false); + } + }, + { isActive: open } + ); + + useEffect(() => { + if (skillsDropdownIndex >= skills.length) { + setSkillsDropdownIndex(Math.max(0, skills.length - 1)); + } + }, [skills.length, skillsDropdownIndex]); + + if (!open) { + return null; + } + + return ( + ({ + key: skill.path || skill.name, + label: skill.name, + description: skill.path, + selected: isSkillSelected(selectedSkills, skill), + statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + }))} + activeIndex={skillsDropdownIndex} + activeColor="#229ac3" + maxVisible={6} + /> + ); +}; + +export default SkillsDropdown; diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts new file mode 100644 index 0000000..635f733 --- /dev/null +++ b/src/ui/components/index.ts @@ -0,0 +1,6 @@ +export { default as RawModelDropdown } from "./RawModelDropdown"; +export { MessageView } from "./MessageView"; +export { RawModeExitPrompt } from "./RawModeExitPrompt"; +export { default as SkillsDropdown } from "./SkillsDropdown"; +export { default as ModelsDropdown } from "./ModelsDropdown"; +export { default as FileMentionMenu } from "./FileMentionMenu"; diff --git a/src/ui/constants.ts b/src/ui/constants.ts new file mode 100644 index 0000000..43372f8 --- /dev/null +++ b/src/ui/constants.ts @@ -0,0 +1,7 @@ +// UI-level shared constants used across components. + +/** Separator used when rendering command arguments inline (e.g., `arg1 | arg2 | arg3`). */ +export const ARGS_SEPARATOR = " | "; + +/** ANSI escape code to clear the screen. */ +export const ANSI_CLEAR_SCREEN = "\u001B[2J\u001B[3J\u001B[H"; diff --git a/src/ui/contexts/AppContext.tsx b/src/ui/contexts/AppContext.tsx new file mode 100644 index 0000000..41b1d1d --- /dev/null +++ b/src/ui/contexts/AppContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; + +export interface AppState { + version: string; +} + +export const AppContext = createContext(null); + +export const useAppContext = (): AppState => { + const context = useContext(AppContext); + if (!context) { + // Safe fallback when App is rendered without AppContainer (e.g., in tests). + return { version: "unknown" }; + } + return context; +}; diff --git a/src/ui/contexts/RawModeContext.tsx b/src/ui/contexts/RawModeContext.tsx new file mode 100644 index 0000000..3198a3a --- /dev/null +++ b/src/ui/contexts/RawModeContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useCallback, useContext, useRef, useState } from "react"; +import type { DropdownMenuItem } from "../DropdownMenu"; + +export enum RawMode { + None = "Normal mode", + Lite = "Lite mode", + Raw = "Raw scrollback mode", +} +export const RAW_COMMAND_MODELS: DropdownMenuItem[] = [ + { + label: "Lite mode", + key: RawMode.Lite, + description: "Collapse chain-of-thought reasoning.", + }, + { + label: "Normal mode", + key: RawMode.None, + description: "Show full chain-of-thought reasoning.", + }, + { + label: "Raw scrollback mode", + key: RawMode.Raw, + description: "Show scrollback mode for copy-friendly terminal selection.", + }, +] as const; + +type RawModeContextValue = { + mode: RawMode; + setMode: React.Dispatch>; + // The mode that was active right before the most recent mode transition. + previousMode: RawMode; +}; + +const RawModeContext = createContext({ + mode: RawMode.Lite, + setMode: () => {}, + previousMode: RawMode.Lite, +}); + +export function useRawModeContext() { + const context = useContext(RawModeContext); + if (!context) { + throw new Error("useRawModeContext must be used within a RawModeProvider"); + } + return context; +} + +export const RawModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [mode, _setMode] = useState(RawMode.Lite); + const previousModeRef = useRef(RawMode.Lite); + + const setMode = useCallback>>((next) => { + _setMode((current) => { + const resolved = typeof next === "function" ? (next as (prev: RawMode) => RawMode)(current) : next; + if (resolved !== current) { + previousModeRef.current = current; + } + return resolved; + }); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/ui/contexts/index.ts b/src/ui/contexts/index.ts new file mode 100644 index 0000000..37e40cd --- /dev/null +++ b/src/ui/contexts/index.ts @@ -0,0 +1,3 @@ +export { AppContext, useAppContext } from "./AppContext"; +export type { AppState } from "./AppContext"; +export { RawMode, RAW_COMMAND_MODELS, useRawModeContext, RawModeProvider } from "./RawModeContext"; diff --git a/src/ui/exitSummary.ts b/src/ui/exitSummary.ts new file mode 100644 index 0000000..c55d9ce --- /dev/null +++ b/src/ui/exitSummary.ts @@ -0,0 +1,144 @@ +import chalk from "chalk"; +import gradientString from "gradient-string"; +import type { ModelUsage, SessionEntry } from "../session"; + +type ExitSummaryInput = { + session: SessionEntry | null; +}; + +const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; + +function visibleLength(text: string): number { + return text.replace(ANSI_RE, "").length; +} + +function padRight(text: string, width: number): string { + const padding = Math.max(0, width - visibleLength(text)); + return text + " ".repeat(padding); +} + +function padLeft(text: string, width: number): string { + const padding = Math.max(0, width - visibleLength(text)); + return " ".repeat(padding) + text; +} + +function formatNumber(n: number): string { + return n.toLocaleString("en-US"); +} + +type UsageFields = { + promptTokens: number; + completionTokens: number; + cachedTokens: number; + totalReqs: number; +}; + +function extractUsageFields(usage: ModelUsage | null): UsageFields { + const empty: UsageFields = { + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + totalReqs: 0, + }; + if (!usage || typeof usage !== "object" || Array.isArray(usage)) { + return empty; + } + + const record = usage as Record; + const promptTokens = typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; + const completionTokens = typeof record.completion_tokens === "number" ? record.completion_tokens : 0; + let cachedTokens = 0; + const promptDetails = record.prompt_tokens_details; + if (promptDetails && typeof promptDetails === "object" && !Array.isArray(promptDetails)) { + const cached = (promptDetails as Record).cached_tokens; + if (typeof cached === "number") { + cachedTokens = cached; + } + } + + // Some providers use prompt_cache_hit_tokens directly + if (cachedTokens === 0 && typeof record.prompt_cache_hit_tokens === "number") { + cachedTokens = record.prompt_cache_hit_tokens; + } + + const totalReqs = typeof record.total_reqs === "number" ? record.total_reqs : 0; + + return { promptTokens, completionTokens, cachedTokens, totalReqs }; +} + +export function buildExitSummaryText(input: ExitSummaryInput): string { + const { session } = input; + + const innerWidth = 98; + const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding + + const borderColor = chalk.hex("#229ac3e6"); + const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); + const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; + + const header = chalk.bold(titleColor("Goodbye!")); + + const rows: string[] = ["", `${header}`, ""]; + + const usageRows = Object.entries(session?.usagePerModel ?? {}) + .map(([modelName, usage]) => ({ + modelName, + usage: extractUsageFields(usage), + })) + .filter( + (row) => + row.usage.totalReqs > 0 || + row.usage.promptTokens > 0 || + row.usage.completionTokens > 0 || + row.usage.cachedTokens > 0 + ) + .sort( + (left, right) => right.usage.totalReqs - left.usage.totalReqs || left.modelName.localeCompare(right.modelName) + ); + const hasUsage = usageRows.length > 0; + + if (hasUsage) { + const colModel = 34; + const colReqs = 8; + const colInput = 16; + const colOutput = 16; + const colCached = 18; + const tableWidth = colModel + colReqs + colInput + colOutput + colCached; + const divider = "─".repeat(tableWidth); + + const headerRow = + padRight("Model Usage", colModel) + + padLeft("Reqs", colReqs) + + padLeft("Input Tokens", colInput) + + padLeft("Output Tokens", colOutput) + + padLeft("Cached Tokens", colCached); + rows.push(chalk.bold(headerRow)); + rows.push(divider); + + for (const { modelName, usage } of usageRows) { + const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); + const inputStr = formatNumber(usage.promptTokens).padStart(colInput); + const outputStr = formatNumber(usage.completionTokens).padStart(colOutput); + const cachedStr = formatNumber(usage.cachedTokens).padStart(colCached); + const dataRow = + padRight(modelName, colModel) + + padRight(reqsStr, colReqs) + + padRight(chalk.yellow(inputStr), colInput) + + padRight(chalk.yellow(outputStr), colOutput) + + padRight(chalk.yellow(cachedStr), colCached); + rows.push(dataRow); + } + + rows.push(""); + } + + rows.push(""); + + const border = borderColor("─".repeat(innerWidth)); + const top = `${borderColor("╭")}${border}${borderColor("╮")}`; + const bottom = `${borderColor("╰")}${border}${borderColor("╯")}`; + + const body = rows.map((row) => line(row)).join("\n"); + + return [top, body, bottom].join("\n"); +} diff --git a/src/ui/fileMentions.ts b/src/ui/fileMentions.ts new file mode 100644 index 0000000..cbacbe6 --- /dev/null +++ b/src/ui/fileMentions.ts @@ -0,0 +1,410 @@ +import * as fs from "fs"; +import * as path from "path"; +import ignore from "ignore"; +import type { PromptBufferState } from "./promptBuffer"; + +export type FileMentionItem = { + path: string; + type: "file" | "directory"; +}; + +export type FileMentionToken = { + query: string; + start: number; + end: number; + quoted: boolean; +}; + +const DEFAULT_MAX_ITEMS = 2000; +const DEFAULT_MAX_DEPTH = 8; + +type IgnoreMatcher = { + base: string; + matcher: ignore.Ignore; +}; + +export function scanFileMentionItems(root: string, maxItems = DEFAULT_MAX_ITEMS): FileMentionItem[] { + const items: FileMentionItem[] = []; + const seen = new Set(); + const gitRoot = findGitRoot(root); + const visitedDirectories = new Set(); + + function addItem(item: FileMentionItem): void { + if (items.length >= maxItems || seen.has(item.path)) { + return; + } + seen.add(item.path); + items.push(item); + } + + function visit(directory: string, depth: number, matchers: IgnoreMatcher[]): void { + if (items.length >= maxItems || depth > DEFAULT_MAX_DEPTH) { + return; + } + + const currentMatchers = [...matchers, ...loadDirectoryIgnoreMatchers(directory, gitRoot)]; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + + entries.sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + if (items.length >= maxItems) { + return; + } + if (entry.name === "." || entry.name === ".." || entry.name === ".git") { + continue; + } + + const absolute = path.join(directory, entry.name); + const relative = toMentionPath(path.relative(root, absolute)); + if (!relative) { + continue; + } + + const entryType = getMentionEntryType(entry, absolute); + if (!entryType) { + continue; + } + + if (matchesAnyIgnore(absolute, entryType === "directory", currentMatchers)) { + continue; + } + + if (entryType === "directory") { + const realPath = safeRealpath(absolute); + if (realPath) { + if (visitedDirectories.has(realPath)) { + continue; + } + visitedDirectories.add(realPath); + } + addItem({ path: `${relative}/`, type: "directory" }); + visit(absolute, depth + 1, currentMatchers); + continue; + } + + if (entryType === "file") { + addItem({ path: relative, type: "file" }); + } + } + } + + const rootRealPath = safeRealpath(root); + if (rootRealPath) { + visitedDirectories.add(rootRealPath); + } + visit(root, 0, loadAncestorIgnoreMatchers(root, gitRoot)); + return items; +} + +function getMentionEntryType(entry: fs.Dirent, absolute: string): FileMentionItem["type"] | null { + if (entry.isDirectory()) { + return "directory"; + } + if (entry.isFile()) { + return "file"; + } + if (!entry.isSymbolicLink()) { + return null; + } + try { + const stat = fs.statSync(absolute); + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isFile()) { + return "file"; + } + } catch { + return null; + } + return null; +} + +function safeRealpath(absolute: string): string | null { + try { + return fs.realpathSync(absolute); + } catch { + return null; + } +} + +function loadDirectoryIgnoreMatchers(directory: string, gitRoot: string | null): IgnoreMatcher[] { + const matchers: IgnoreMatcher[] = []; + if (gitRoot && isPathInsideOrEqual(directory, gitRoot)) { + const gitignoreMatcher = loadIgnoreFileMatcher(directory, path.join(directory, ".gitignore")); + if (gitignoreMatcher) { + matchers.push(gitignoreMatcher); + } + if (path.resolve(directory) === path.resolve(gitRoot)) { + const gitExcludeMatcher = loadIgnoreFileMatcher(directory, path.join(directory, ".git", "info", "exclude")); + if (gitExcludeMatcher) { + matchers.push(gitExcludeMatcher); + } + } + } + + const ignoreMatcher = loadIgnoreFileMatcher(directory, path.join(directory, ".ignore")); + if (ignoreMatcher) { + matchers.push(ignoreMatcher); + } + return matchers; +} + +function loadAncestorIgnoreMatchers(root: string, gitRoot: string | null): IgnoreMatcher[] { + const resolvedRoot = path.resolve(root); + const ancestors: string[] = []; + let current = path.dirname(resolvedRoot); + while (gitRoot && isPathInsideOrEqual(current, gitRoot)) { + ancestors.push(current); + if (path.resolve(current) === path.resolve(gitRoot)) { + break; + } + current = path.dirname(current); + } + return ancestors.reverse().flatMap((directory) => loadDirectoryIgnoreMatchers(directory, gitRoot)); +} + +function loadIgnoreFileMatcher(base: string, ignoreFilePath: string): IgnoreMatcher | null { + try { + if (!fs.existsSync(ignoreFilePath)) { + return null; + } + const content = fs.readFileSync(ignoreFilePath, "utf8"); + if (!content.trim()) { + return null; + } + return { base, matcher: ignore().add(content) }; + } catch { + return null; + } +} + +function matchesAnyIgnore(absolute: string, isDir: boolean, matchers: IgnoreMatcher[]): boolean { + let ignored = false; + for (const { base, matcher } of matchers) { + const relative = toMentionPath(path.relative(base, absolute)); + if (!relative || relative.startsWith("../")) { + continue; + } + const result = matcher.test(isDir ? `${relative}/` : relative); + if (result.ignored) { + ignored = true; + } + if (result.unignored) { + ignored = false; + } + } + return ignored; +} + +function findGitRoot(start: string): string | null { + let current = path.resolve(start); + while (true) { + if (fs.existsSync(path.join(current, ".git"))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function isPathInsideOrEqual(candidate: string, parent: string): boolean { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function filterFileMentionItems(items: FileMentionItem[], query: string, maxResults = 12): FileMentionItem[] { + const normalizedQuery = normalizeForSearch(query); + const scored = items + .map((item, index) => ({ item, index, score: scoreFileMention(item.path, normalizedQuery) })) + .filter((entry) => entry.score !== Number.POSITIVE_INFINITY) + .sort((a, b) => a.score - b.score || a.item.path.length - b.item.path.length || a.index - b.index); + + return scored.slice(0, maxResults).map((entry) => entry.item); +} + +export function getCurrentFileMentionToken(state: PromptBufferState): FileMentionToken | null { + const text = state.text; + const cursor = clampCursorToBoundary(text, state.cursor); + const quoted = getCurrentQuotedFileMentionToken(text, cursor); + if (quoted) { + return quoted; + } + return getCurrentBareFileMentionToken(text, cursor); +} + +export function replaceCurrentFileMentionToken( + state: PromptBufferState, + token: FileMentionToken, + selectedPath: string +): PromptBufferState { + const inserted = `${formatFileMentionPath(selectedPath)} `; + const end = token.end < state.text.length && isWhitespace(state.text[token.end] ?? "") ? token.end + 1 : token.end; + const text = `${state.text.slice(0, token.start)}${inserted}${state.text.slice(end)}`; + return { text, cursor: token.start + inserted.length }; +} + +export function formatFileMentionPath(filePath: string): string { + if (!/[\s"]/.test(filePath)) { + return `@${filePath}`; + } + return `@"${filePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function getCurrentBareFileMentionToken(text: string, cursor: number): FileMentionToken | null { + const beforeCursor = text.slice(0, cursor); + const afterCursor = text.slice(cursor); + const start = findTokenStart(beforeCursor); + const end = cursor + findTokenEnd(afterCursor); + const token = text.slice(start, end); + + if (!token.startsWith("@") || token.startsWith('@"')) { + return null; + } + if (start > 0 && !isWhitespace(text[start - 1] ?? "")) { + return null; + } + return { query: token.slice(1), start, end, quoted: false }; +} + +function getCurrentQuotedFileMentionToken(text: string, cursor: number): FileMentionToken | null { + for (let index = cursor; index >= 0; index--) { + if (text[index] !== "@" || text[index + 1] !== '"') { + continue; + } + if (index > 0 && !isWhitespace(text[index - 1] ?? "")) { + continue; + } + + const closeQuote = findClosingQuote(text, index + 2); + if (closeQuote !== -1 && cursor > closeQuote) { + continue; + } + + const end = closeQuote === -1 ? cursor : closeQuote + 1; + return { + query: unescapeQuotedMentionQuery( + text.slice(index + 2, Math.min(cursor, closeQuote === -1 ? cursor : closeQuote)) + ), + start: index, + end, + quoted: true, + }; + } + return null; +} + +function findTokenStart(beforeCursor: string): number { + const whitespaceIndex = findLastWhitespaceIndex(beforeCursor); + return whitespaceIndex === -1 ? 0 : whitespaceIndex + 1; +} + +function findTokenEnd(afterCursor: string): number { + const whitespaceIndex = afterCursor.search(/\s/); + return whitespaceIndex === -1 ? afterCursor.length : whitespaceIndex; +} + +function findLastWhitespaceIndex(value: string): number { + for (let index = value.length - 1; index >= 0; index--) { + if (isWhitespace(value[index] ?? "")) { + return index; + } + } + return -1; +} + +function findClosingQuote(text: string, start: number): number { + let escaped = false; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + return index; + } + } + return -1; +} + +function unescapeQuotedMentionQuery(query: string): string { + return query.replace(/\\(["\\])/g, "$1"); +} + +function clampCursorToBoundary(text: string, cursor: number): number { + return Math.max(0, Math.min(cursor, text.length)); +} + +function scoreFileMention(itemPath: string, normalizedQuery: string): number { + if (!normalizedQuery) { + return itemPath.endsWith("/") ? 5 : 10; + } + + const normalizedPath = normalizeForSearch(itemPath); + const normalizedBase = normalizeForSearch(path.posix.basename(itemPath.replace(/\/$/, ""))); + if (normalizedPath === normalizedQuery) { + return 0; + } + if (normalizedPath.startsWith(normalizedQuery)) { + return 1; + } + if (normalizedBase.startsWith(normalizedQuery)) { + return isQueryBoundary(normalizedBase[normalizedQuery.length] ?? "") ? 2 : 3; + } + const pathIndex = normalizedPath.indexOf(normalizedQuery); + if (pathIndex !== -1) { + return 20 + pathIndex; + } + const fuzzyScore = fuzzyMatchScore(normalizedPath, normalizedQuery); + return fuzzyScore === null ? Number.POSITIVE_INFINITY : 100 + fuzzyScore; +} + +function fuzzyMatchScore(value: string, query: string): number | null { + let valueIndex = 0; + let score = 0; + for (const char of query) { + const nextIndex = value.indexOf(char, valueIndex); + if (nextIndex === -1) { + return null; + } + score += nextIndex - valueIndex; + valueIndex = nextIndex + 1; + } + return score; +} + +function normalizeForSearch(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function isQueryBoundary(value: string): boolean { + return value === "" || /[\s._/-]/.test(value); +} + +function toMentionPath(value: string): string { + return value.split(path.sep).join("/"); +} + +function isWhitespace(value: string): boolean { + return /\s/.test(value); +} diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..1348903 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,99 @@ +import { + getThinkingOptionIndex, + MODEL_COMMAND_MODELS, + MODEL_COMMAND_THINKING_OPTIONS, +} from "./components/ModelsDropdown"; + +export { + readSettings, + readProjectSettings, + writeSettings, + writeProjectSettings, + writeModelConfigSelection, + resolveCurrentSettings, + buildPromptDraftFromSessionMessage, +} from "./App"; +export { createOpenAIClient } from "../common/openai-client"; +export { default as AppContainer } from "./AppContainer"; +export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; +export { MessageView } from "./components"; +export { parseDiffPreview } from "./components/MessageView/utils"; +export { + PromptInput, + IMAGE_ATTACHMENT_CLEAR_HINT, + formatImageAttachmentStatus, + formatSelectedSkillsStatus, + addUniqueSkill, + toggleSkillSelection, + removeCurrentSlashToken, + isClearImageAttachmentsShortcut, + getPromptReturnKeyAction, + renderBufferWithCursor, + buildInitPromptSubmission, + useTerminalInput, + parseTerminalInput, + dispatchTerminalInput, + type PromptSubmission, + type PromptDraft, + type InputKey, +} from "./PromptInput"; +export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; +export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; +export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./SessionList"; +export { ThemedGradient } from "./ThemedGradient"; +export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; +export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen"; +export { + findPendingAskUserQuestion, + formatAskUserQuestionAnswers, + formatAskUserQuestionDecline, + type AskUserQuestionOption, + type AskUserQuestionItem, + type PendingAskUserQuestion, + type AskUserQuestionAnswers, +} from "./askUserQuestion"; +export { readClipboardImage, type ClipboardImage } from "./clipboard"; +export { buildLoadingText, type LoadingTextInput } from "./loadingText"; +export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; +export { + EMPTY_BUFFER, + insertText, + backspace, + deleteForward, + moveLeft, + moveRight, + moveWordLeft, + moveWordRight, + moveUp, + moveDown, + moveLineStart, + moveLineEnd, + killLine, + deleteWordBefore, + deleteWordAfter, + reset, + isEmpty, + getCurrentSlashToken, + type PromptBufferState, +} from "./promptBuffer"; +export { + BUILTIN_SLASH_COMMANDS, + buildSlashCommands, + filterSlashCommands, + findExactSlashCommand, + formatSlashCommandDescription, + formatSlashCommandLabel, + type SlashCommandKind, + type SlashCommandItem, +} from "./slashCommands"; +export { + filterFileMentionItems, + formatFileMentionPath, + getCurrentFileMentionToken, + replaceCurrentFileMentionToken, + scanFileMentionItems, + type FileMentionItem, + type FileMentionToken, +} from "./fileMentions"; +export { findExpandedThinkingId } from "./thinkingState"; +export { buildExitSummaryText } from "./exitSummary"; diff --git a/src/ui/markdown.ts b/src/ui/markdown.ts deleted file mode 100644 index aa77423..0000000 --- a/src/ui/markdown.ts +++ /dev/null @@ -1,119 +0,0 @@ -import chalk from "chalk"; - -export function renderMarkdown(text: string): string { - if (!text) { - return ""; - } - - const fenceSegments = splitByFences(text); - return fenceSegments - .map((segment) => { - if (segment.kind === "code") { - const langTag = segment.lang ? chalk.dim(`[${segment.lang}]`) + "\n" : ""; - return langTag + chalk.cyan(segment.body); - } - return renderInlineBlock(segment.body); - }) - .join(""); -} - -type FenceSegment = - | { kind: "text"; body: string } - | { kind: "code"; lang: string; body: string }; - -function splitByFences(text: string): FenceSegment[] { - const segments: FenceSegment[] = []; - const lines = text.split(/\r?\n/); - let buffer: string[] = []; - let inFence = false; - let fenceLang = ""; - let fenceBody: string[] = []; - - const flushText = () => { - if (buffer.length === 0) { - return; - } - segments.push({ kind: "text", body: buffer.join("\n") }); - buffer = []; - }; - - for (const line of lines) { - const fenceMatch = /^\s*```(\w*)\s*$/.exec(line); - if (fenceMatch) { - if (!inFence) { - flushText(); - inFence = true; - fenceLang = fenceMatch[1] ?? ""; - fenceBody = []; - } else { - segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); - inFence = false; - fenceLang = ""; - fenceBody = []; - } - continue; - } - - if (inFence) { - fenceBody.push(line); - } else { - buffer.push(line); - } - } - - if (inFence) { - segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); - } else { - flushText(); - } - - return segments; -} - -function renderInlineBlock(text: string): string { - return text - .split("\n") - .map((line) => renderInlineLine(line)) - .join("\n"); -} - -function renderInlineLine(line: string): string { - const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); - if (headingMatch) { - const [, lead, hashes, content] = headingMatch; - const styled = hashes.length <= 2 ? chalk.bold.cyanBright(content) : chalk.bold.cyan(content); - return `${lead}${chalk.dim(hashes)} ${styled}`; - } - - const listMatch = /^(\s*)([-*+])\s+(.*)$/.exec(line); - if (listMatch) { - const [, lead, bullet, content] = listMatch; - return `${lead}${chalk.yellow(bullet)} ${renderInlineSpans(content)}`; - } - - const numListMatch = /^(\s*)(\d+\.)\s+(.*)$/.exec(line); - if (numListMatch) { - const [, lead, marker, content] = numListMatch; - return `${lead}${chalk.yellow(marker)} ${renderInlineSpans(content)}`; - } - - const quoteMatch = /^(\s*)>\s?(.*)$/.exec(line); - if (quoteMatch) { - const [, lead, content] = quoteMatch; - return `${lead}${chalk.dim("│ ")}${chalk.italic(renderInlineSpans(content))}`; - } - - return renderInlineSpans(line); -} - -function renderInlineSpans(text: string): string { - if (!text) { - return text; - } - let result = text; - result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); - result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); - result = result.replace(/(? chalk.italic(inner)); - result = result.replace(/_([^_\n]+)_/g, (_, inner) => chalk.italic(inner)); - return result; -} diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts new file mode 100644 index 0000000..aefea34 --- /dev/null +++ b/src/ui/prompt/cursor.ts @@ -0,0 +1,283 @@ +import { useLayoutEffect, useRef } from "react"; +import type { PromptBufferState } from "../promptBuffer"; + +type CursorPlacement = { + rowsUp: number; + column: number; +}; + +type WriteFn = ( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void +) => boolean; + +function cursorUp(rows: number): string { + return rows > 0 ? `\u001B[${rows}A` : ""; +} + +function cursorDown(rows: number): string { + return rows > 0 ? `\u001B[${rows}B` : ""; +} + +function cursorForward(columns: number): string { + return columns > 0 ? `\u001B[${columns}C` : ""; +} + +function showCursor(): string { + return "\u001B[?25h"; +} + +function hideCursor(): string { + return "\u001B[?25l"; +} + +function enableTerminalFocusReporting(): string { + return "\u001B[?1004h"; +} + +function disableTerminalFocusReporting(): string { + return "\u001B[?1004l"; +} + +function enableBracketedPaste(): string { + return "\u001B[?2004h"; +} + +function disableBracketedPaste(): string { + return "\u001B[?2004l"; +} + +export function enableTerminalExtendedKeys(): string { + return "\u001B[>4;1m"; +} + +export function disableTerminalExtendedKeys(): string { + return "\u001B[>4;0m"; +} + +export function getPromptCursorPlacement( + state: PromptBufferState, + screenWidth: number, + prefixWidth: number, + footerText: string +): CursorPlacement { + const width = Math.max(1, screenWidth); + const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); + const beforeCursor = state.text.slice(0, cursor); + const at = state.text[cursor]; + const displayText = + beforeCursor + + (typeof at === "undefined" || at === "\n" ? " " : at) + + (at === "\n" ? "\n" : "") + + (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); + + const cursorPosition = measureTextPosition(beforeCursor, width, prefixWidth); + const promptRows = measureTextRows(displayText, width, prefixWidth); + const footerRows = 1 + measureTextRows(footerText, width, 0); + + return { + rowsUp: promptRows - 1 - cursorPosition.row + footerRows + 1, + column: cursorPosition.column, + }; +} + +function measureTextRows(text: string, width: number, initialColumn: number): number { + return measureTextPosition(text, width, initialColumn).row + 1; +} + +function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { + let row = 0; + let column = Math.min(initialColumn, width - 1); + + for (const char of Array.from(text)) { + if (char === "\n") { + row++; + column = Math.min(initialColumn, width - 1); + continue; + } + + const charColumns = textWidth(char); + if (column + charColumns > width) { + row++; + column = Math.min(initialColumn, width - 1); + } + column += charColumns; + if (column >= width) { + row++; + column = Math.min(initialColumn, width - 1); + } + } + + return { row, column }; +} + +function textWidth(value: string): number { + let width = 0; + for (const char of Array.from(value.normalize())) { + width += characterWidth(char); + } + return width; +} + +function characterWidth(char: string): number { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { + return 0; + } + if (codePoint >= 0x300 && codePoint <= 0x36f) { + return 0; + } + if ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) + ) { + return 2; + } + return 1; +} + +export function usePromptTerminalCursor( + stdout: NodeJS.WriteStream | undefined, + placement: CursorPlacement, + isActive: boolean +): void { + const directWriteRef = useRef<((data: string) => void) | null>(null); + const activePlacementRef = useRef(null); + const lastPlacementRef = useRef(null); + const unmountingRef = useRef(false); + + useLayoutEffect(() => { + if (!stdout?.isTTY) { + return; + } + + const stream = stdout as NodeJS.WriteStream & { write: WriteFn }; + const originalWrite = stream.write; + const directWrite = (data: string) => { + originalWrite.call(stdout, data); + }; + const restorePromptCursor = () => { + if (unmountingRef.current) { + return; + } + const activePlacement = activePlacementRef.current; + if (!activePlacement) { + return; + } + directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); + activePlacementRef.current = null; + // Schedule a deferred re-position in case the layout effect does not + // re-run (e.g. a dropdown closed without changing the buffer). + Promise.resolve().then(() => { + if (unmountingRef.current || activePlacementRef.current) { + return; + } + const latest = directWriteRef.current; + const p = lastPlacementRef.current; + if (latest && p) { + latest(showCursor() + cursorUp(p.rowsUp) + "\r" + cursorForward(p.column)); + activePlacementRef.current = p; + } + }); + }; + const patchedWrite: WriteFn = (...args) => { + restorePromptCursor(); + return originalWrite.apply(stdout, args); + }; + + directWriteRef.current = directWrite; + stream.write = patchedWrite; + + return () => { + restorePromptCursor(); + stream.write = originalWrite; + directWriteRef.current = null; + }; + }, [stdout]); + + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + unmountingRef.current = false; + const directWrite = directWriteRef.current; + if (!directWrite) { + return; + } + + directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); + activePlacementRef.current = placement; + lastPlacementRef.current = placement; + + return () => { + unmountingRef.current = true; + lastPlacementRef.current = null; + const activePlacement = activePlacementRef.current; + if (!activePlacement) { + return; + } + directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); + activePlacementRef.current = null; + }; + }, [isActive, placement, stdout]); +} + +export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(hideCursor()); + return () => { + stdout.write(showCursor()); + }; + }, [isActive, stdout]); +} + +export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalFocusReporting()); + return () => { + stdout.write(disableTerminalFocusReporting()); + }; + }, [isActive, stdout]); +} + +export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalExtendedKeys()); + return () => { + stdout.write(disableTerminalExtendedKeys()); + }; + }, [isActive, stdout]); +} + +export function useBracketedPaste(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableBracketedPaste()); + return () => { + stdout.write(disableBracketedPaste()); + }; + }, [isActive, stdout]); +} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts new file mode 100644 index 0000000..6435f62 --- /dev/null +++ b/src/ui/prompt/index.ts @@ -0,0 +1,11 @@ +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./useTerminalInput"; +export type { InputKey } from "./useTerminalInput"; + +export { + useHiddenTerminalCursor, + useTerminalExtendedKeys, + useBracketedPaste, + usePromptTerminalCursor, + useTerminalFocusReporting, + getPromptCursorPlacement, +} from "./cursor"; diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts new file mode 100644 index 0000000..e3d6349 --- /dev/null +++ b/src/ui/prompt/useTerminalInput.ts @@ -0,0 +1,345 @@ +import { useEffect, useRef } from "react"; +import { useStdin } from "ink"; + +export type InputKey = { + upArrow: boolean; + downArrow: boolean; + leftArrow: boolean; + rightArrow: boolean; + home: boolean; + end: boolean; + pageDown: boolean; + pageUp: boolean; + return: boolean; + escape: boolean; + ctrl: boolean; + shift: boolean; + tab: boolean; + backspace: boolean; + delete: boolean; + meta: boolean; + focusIn: boolean; + focusOut: boolean; + /** True when the input came from a bracketed paste (ESC[200~ ... ESC[201~). */ + paste: boolean; +}; + +const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); +const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); +const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); +const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); +const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]); +const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); +const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); +const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]); +const META_LEFT_SEQUENCES = new Set(["\u001B[1;3D", "\u001B[3D", "\u001Bb"]); +const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); +const TERMINAL_FOCUS_IN = "\u001B[I"; +const TERMINAL_FOCUS_OUT = "\u001B[O"; + +// Bracketed paste mode markers (xterm-style). +// When the terminal supports bracketed paste, pasted text is wrapped with: +// ESC[200~ ...pasted content... ESC[201~ +const PASTE_START = "\u001B[200~"; +const PASTE_END = "\u001B[201~"; +const PASTE_END_LENGTH = 6; // length of PASTE_END + +// Ctrl+- (minus) sequences in modifyOtherKeys mode. +// \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl) +// \u001B[27;5;45~ — extended format for function-like reporting +const CTRL_MINUS_SEQUENCES = new Set(["\u001B[45;5u", "\u001B[27;5;45~"]); + +// Ctrl+Shift+- (minus) sequences in modifyOtherKeys mode. +// \u001B[45;6u — standard format: keycode=45 ('-'), modifier=6 (Ctrl+Shift) +// \u001B[27;6;45~ — extended format for function-like reporting +const CTRL_SHIFT_MINUS_SEQUENCES = new Set(["\u001B[45;6u", "\u001B[27;6;45~"]); + +export function parseTerminalInput(data: Buffer | string): { input: string; key: InputKey } { + const raw = String(data); + let input = raw; + + // Ctrl+- undo shortcut: only via modifyOtherKeys CSI sequences. + // Raw 0x1F is NOT included here because it represents Ctrl+_ (Ctrl+Shift+- + // on US keyboards), which should trigger redo instead. + if (CTRL_MINUS_SEQUENCES.has(raw)) { + input = "-"; + const key: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: true, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: false, + }; + return { input, key }; + } + + // Ctrl+Shift+- redo shortcut: modifyOtherKeys CSI sequences + raw 0x1F fallback. + // \x1F is Ctrl+_ which on US keyboards = Ctrl+Shift+-. + if (CTRL_SHIFT_MINUS_SEQUENCES.has(raw) || raw === "\u001F") { + input = "-"; + const key: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: true, + shift: true, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: false, + }; + return { input, key }; + } + + const key: InputKey = { + upArrow: raw === "\u001B[A", + downArrow: raw === "\u001B[B", + leftArrow: raw === "\u001B[D" || CTRL_LEFT_SEQUENCES.has(raw) || META_LEFT_SEQUENCES.has(raw), + rightArrow: raw === "\u001B[C" || CTRL_RIGHT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw), + home: HOME_SEQUENCES.has(raw), + end: END_SEQUENCES.has(raw), + pageDown: raw === "\u001B[6~", + pageUp: raw === "\u001B[5~", + return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), + escape: raw === "\u001B", + ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw), + shift: SHIFT_RETURN_SEQUENCES.has(raw), + tab: raw === "\t" || raw === "\u001B[Z", + backspace: BACKSPACE_BYTES.has(raw), + delete: FORWARD_DELETE_SEQUENCES.has(raw), + meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), + focusIn: raw === TERMINAL_FOCUS_IN, + focusOut: raw === TERMINAL_FOCUS_OUT, + paste: false, + }; + + if (input <= "\u001A" && !key.return) { + input = String.fromCharCode(input.charCodeAt(0) + "a".charCodeAt(0) - 1); + key.ctrl = true; + } + + const isKnownEscapeSequence = + key.upArrow || + key.downArrow || + key.leftArrow || + key.rightArrow || + key.home || + key.end || + key.pageDown || + key.pageUp || + key.tab || + key.delete || + key.return || + key.ctrl || + key.meta || + key.focusIn || + key.focusOut; + + if (raw.startsWith("\u001B")) { + input = raw.slice(1); + key.meta = key.meta || !isKnownEscapeSequence; + } + + const isLatinUppercase = input >= "A" && input <= "Z"; + const isCyrillicUppercase = input >= "А" && input <= "Я"; + if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) { + key.shift = true; + } + + if (key.tab && input === "[Z") { + key.shift = true; + } + + if (key.tab || key.backspace || key.delete) { + input = ""; + } + + return { input, key }; +} + +export function dispatchTerminalInput( + data: Buffer | string, + inputHandler: (input: string, key: InputKey) => void +): void { + const raw = String(data); + + // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.). + // iOS keyboards can send composed characters as a single packet like: + // "가\x7f나" (character + backspace + replacement character) + // Do not split escape-prefixed sequences such as Alt+Backspace. + if (!raw.startsWith("\u001B") && raw.includes("\x7f") && raw.length > 1) { + const parts = raw.split("\x7f"); + if (parts[0]) { + const { input, key } = parseTerminalInput(parts[0]); + inputHandler(input, key); + } + for (let i = 1; i < parts.length; i++) { + const bs = parseTerminalInput("\x7f"); + inputHandler(bs.input, bs.key); + if (parts[i]) { + const { input, key } = parseTerminalInput(parts[i]); + inputHandler(input, key); + } + } + return; + } + + const { input, key } = parseTerminalInput(data); + inputHandler(input, key); +} + +/** An InputKey with all fields false (including paste). Used when dispatching paste events. */ +const EMPTY_KEY: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: false, +}; + +export function useTerminalInput( + inputHandler: (input: string, key: InputKey) => void, + options: { isActive?: boolean } = {} +): void { + const { stdin, setRawMode } = useStdin(); + const isActive = options.isActive ?? true; + const handlerRef = useRef(inputHandler); + handlerRef.current = inputHandler; + + // Mutable paste-bracketing state shared across data events. + // Uses an array of chunks instead of string concatenation to avoid + // O(n²) copying when the terminal splits a large paste across many events. + const pasteRef = useRef({ active: false, chunks: [] as string[] }); + + useEffect(() => { + if (!isActive) { + pasteRef.current.active = false; + pasteRef.current.chunks = []; + return; + } + setRawMode(true); + return () => { + setRawMode(false); + }; + }, [isActive, setRawMode]); + + useEffect(() => { + if (!isActive) { + return; + } + + const handleData = (data: Buffer | string) => { + const raw = String(data); + + // ----- Bracketed paste handling ----- + // Most terminals send the start/end markers in the same chunk as + // the content. We handle both inline and multi-chunk scenarios. + + if (raw.includes(PASTE_START)) { + pasteRef.current.active = true; + pasteRef.current.chunks = []; + + // Extract content after the start marker. + const startIdx = raw.indexOf(PASTE_START); + const afterStart = raw.slice(startIdx + PASTE_START.length); + + // Check if the end marker is also in this same chunk. + const endIdx = afterStart.indexOf(PASTE_END); + if (endIdx !== -1) { + // Both markers in one chunk — process immediately. + const pasteContent = afterStart.slice(0, endIdx); + pasteRef.current.active = false; + const remaining = afterStart.slice(endIdx + PASTE_END_LENGTH); + + if (pasteContent.length > 0) { + handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } + if (remaining.length > 0) { + dispatchTerminalInput(remaining, handlerRef.current); + } + return; + } + + // Only start marker — buffer as first chunk. + if (afterStart) { + pasteRef.current.chunks.push(afterStart); + } + return; + } + + if (pasteRef.current.active) { + pasteRef.current.chunks.push(raw); + // Only join+search when this chunk might contain the end marker. + if (raw.includes("201~")) { + const combined = pasteRef.current.chunks.join(""); + const endIdx = combined.indexOf(PASTE_END); + if (endIdx !== -1) { + const pasteContent = combined.slice(0, endIdx); + pasteRef.current.active = false; + const remaining = combined.slice(endIdx + PASTE_END_LENGTH); + pasteRef.current.chunks = []; + + // Dispatch the pasted text as a single event. + if (pasteContent.length > 0) { + handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } + + // Handle any remaining input after the paste end marker. + if (remaining.length > 0) { + dispatchTerminalInput(remaining, handlerRef.current); + } + return; + } + return; + } + return; + } + + // ----- Normal (non-paste) input ----- + dispatchTerminalInput(data, handlerRef.current); + }; + + stdin?.on("data", handleData); + return () => { + stdin?.off("data", handleData); + }; + }, [isActive, stdin]); +} diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index f45d422..3e0a710b 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -123,7 +123,25 @@ export function deleteWordBefore(state: PromptBufferState): PromptBufferState { } return { text: state.text.slice(0, start) + state.text.slice(end), - cursor: start + cursor: start, + }; +} + +export function deleteWordAfter(state: PromptBufferState): PromptBufferState { + const start = state.cursor; + let end = start; + while (end < state.text.length && /\s/.test(state.text[end] ?? "")) { + end++; + } + while (end < state.text.length && !/\s/.test(state.text[end] ?? "")) { + end++; + } + if (start === end) { + return state; + } + return { + text: state.text.slice(0, start) + state.text.slice(end), + cursor: start, }; } @@ -153,6 +171,141 @@ export function getCurrentSlashToken(state: PromptBufferState): string | null { return line; } +/** + * Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. + * When the user pastes a large block of text (>10 lines or >1000 chars), a compact + * marker is inserted instead of the full content. The actual content is stored in a + * Map and expanded back before submission. + */ +export const PASTE_MARKER_REGEX = /\[paste #(\d+) (\+?\d+ lines|\d+ chars)\]/g; + +/** + * Find the paste marker that ends exactly at `state.cursor`, if any. + * Returns the marker's start and end positions, or `null`. + */ +export function findPasteMarkerBefore(state: PromptBufferState): { start: number; end: number } | null { + // Walk backwards through all markers and return the one that ends at the cursor. + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index + match[0].length === state.cursor) { + return { start: match.index, end: match.index + match[0].length }; + } + } + return null; +} + +/** + * Find the paste marker that starts exactly at `state.cursor`, if any. + * Returns the marker's start and end positions, or `null`. + */ +export function findPasteMarkerAt(state: PromptBufferState): { start: number; end: number } | null { + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index === state.cursor) { + return { start: match.index, end: match.index + match[0].length }; + } + } + return null; +} + +/** + * If the cursor is immediately after a paste marker, delete the entire marker + * (atomic backspace). Returns the new state, or `state` unchanged if no marker. + */ +export function deletePasteMarkerBackward( + state: PromptBufferState, + validIds: Map +): PromptBufferState | null { + const marker = findPasteMarkerBefore(state); + if (!marker) return null; + // Only delete if this is a real paste marker (ID in validIds). + PASTE_MARKER_REGEX.lastIndex = 0; + const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end)); + if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null; + const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); + return { text, cursor: marker.start }; +} + +/** + * If the cursor is at the start of a paste marker, delete the entire marker + * (atomic forward delete). Returns the new state, or `state` unchanged if no marker. + */ +export function deletePasteMarkerForward( + state: PromptBufferState, + validIds: Map +): PromptBufferState | null { + const marker = findPasteMarkerAt(state); + if (!marker) return null; + // Only delete if this is a real paste marker (ID in validIds). + PASTE_MARKER_REGEX.lastIndex = 0; + const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end)); + if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null; + const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); + return { text, cursor: marker.start }; +} + +/** + * Sanitize stored paste content (filter control chars, expand tabs). + * Called lazily on expand/submit, not during paste to keep paste instant. + */ +export function cleanPasteContent(text: string): string { + return text + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); +} + +/** + * Expand paste markers in the text back to their original (cleaned) content. + * @param text - Text potentially containing paste markers. + * @param pastes - Map of paste ID → original content. + */ +export function expandPasteMarkers(text: string, pastes: Map): string { + if (pastes.size === 0) return text; + let result = text; + for (const [pasteId, pasteContent] of pastes) { + const markerRegex = new RegExp(`\\[paste #${pasteId} (\\+?\\d+ lines|\\d+ chars)\\]`, "g"); + result = result.replace(markerRegex, () => cleanPasteContent(pasteContent)); + } + return result; +} + +/** + * Find the paste marker that contains `state.cursor`, if any. + * Returns the marker's start, end, and numeric paste ID, or `null`. + */ +export function findPasteMarkerContaining(state: PromptBufferState): { start: number; end: number; id: number } | null { + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index <= state.cursor && match.index + match[0].length >= state.cursor) { + return { + start: match.index, + end: match.index + match[0].length, + id: Number.parseInt(match[1]!, 10), + }; + } + } + return null; +} + +/** + * Check whether the text contains real paste markers (IDs present in validIds). + */ +export function hasActivePasteMarkers(text: string, validIds: Map): boolean { + if (!text.includes("[paste #")) return false; + PASTE_MARKER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) { + if (validIds.has(Number.parseInt(match[1]!, 10))) { + return true; + } + } + return false; +} + function locate(state: PromptBufferState): { line: number; column: number; @@ -169,6 +322,6 @@ function locate(state: PromptBufferState): { line: lineNumber, column: state.cursor - lineStart, lineStart, - lineEnd + lineEnd, }; } diff --git a/src/ui/promptUndoRedo.ts b/src/ui/promptUndoRedo.ts new file mode 100644 index 0000000..9d30f57 --- /dev/null +++ b/src/ui/promptUndoRedo.ts @@ -0,0 +1,52 @@ +import type { PromptBufferState } from "./promptBuffer"; + +export type PromptUndoRedoState = { + undoStack: PromptBufferState[]; + redoStack: PromptBufferState[]; +}; + +export function createPromptUndoRedoState(): PromptUndoRedoState { + return { undoStack: [], redoStack: [] }; +} + +export function recordPromptEdit( + history: PromptUndoRedoState, + current: PromptBufferState, + next: PromptBufferState, + maxUndoEntries = 1000 +): void { + if (next.text === current.text || next.text === history.undoStack.at(-1)?.text) { + return; + } + + history.undoStack.push(current); + if (history.undoStack.length > maxUndoEntries) { + history.undoStack = history.undoStack.slice(-maxUndoEntries); + } + history.redoStack = []; +} + +export function undoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null { + const previous = history.undoStack.pop(); + if (!previous) { + return null; + } + + history.redoStack.push(current); + return previous; +} + +export function redoPromptEdit(history: PromptUndoRedoState, current: PromptBufferState): PromptBufferState | null { + const next = history.redoStack.pop(); + if (!next) { + return null; + } + + history.undoStack.push(current); + return next; +} + +export function clearPromptUndoRedoState(history: PromptUndoRedoState): void { + history.undoStack = []; + history.redoStack = []; +} diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index d6709f5..6d9b7cc 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,17 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "new" | "resume" | "exit"; +export type SlashCommandKind = + | "skill" + | "skills" + | "model" + | "new" + | "init" + | "resume" + | "continue" + | "undo" + | "mcp" + | "raw" + | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -8,6 +19,7 @@ export type SlashCommandItem = { label: string; description: string; skill?: SkillInfo; + args?: string[]; }; export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ @@ -15,26 +27,63 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ kind: "skills", name: "skills", label: "/skills", - description: "List available skills" + description: "List available skills", + }, + { + kind: "model", + name: "model", + label: "/model", + description: "Select model, thinking mode and effort control", }, { kind: "new", name: "new", label: "/new", - description: "Start a fresh conversation" + description: "Start a fresh conversation", + }, + { + kind: "init", + name: "init", + label: "/init", + description: "Initialize an AGENTS.md file with instructions for LLM", }, { kind: "resume", name: "resume", label: "/resume", - description: "Pick a previous conversation to continue" + description: "Pick a previous conversation to continue", + }, + { + kind: "continue", + name: "continue", + label: "/continue", + description: "Continue the active conversation or pick one to resume", + }, + { + kind: "undo", + name: "undo", + label: "/undo", + description: "Restore code and/or conversation to a previous point", + }, + { + kind: "mcp", + name: "mcp", + label: "/mcp", + description: "Show MCP server status and available tools", + }, + { + kind: "raw", + name: "raw", + label: "/raw", + args: ["lite", "normal", "raw-scrollback"], + description: "Toggle display mode for viewing or collapsing reasoning content", }, { kind: "exit", name: "exit", label: "/exit", - description: "Quit Deep Code CLI" - } + description: "Quit Deep Code CLI", + }, ]; export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { @@ -43,15 +92,12 @@ export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { name: skill.name, label: `/${skill.name}`, description: skill.description || "(no description)", - skill + skill, })); return [...skillItems, ...BUILTIN_SLASH_COMMANDS]; } -export function filterSlashCommands( - items: SlashCommandItem[], - token: string -): SlashCommandItem[] { +export function filterSlashCommands(items: SlashCommandItem[], token: string): SlashCommandItem[] { if (!token.startsWith("/")) { return []; } @@ -62,10 +108,7 @@ export function filterSlashCommands( return items.filter((item) => item.name.toLowerCase().includes(query)); } -export function findExactSlashCommand( - items: SlashCommandItem[], - token: string -): SlashCommandItem | null { +export function findExactSlashCommand(items: SlashCommandItem[], token: string): SlashCommandItem | null { if (!token.startsWith("/")) { return null; } diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts new file mode 100644 index 0000000..4b498a0 --- /dev/null +++ b/src/ui/utils/index.ts @@ -0,0 +1,24 @@ +import chalk from "chalk"; +import type { SessionMessage } from "../../session"; +import { renderMessageToStdout } from "../components/MessageView/utils"; +import type { RawMode } from "../contexts"; + +/** + * Render all messages directly to stdout for Raw mode display. + * Writes each message followed by the "Press ESC to exit raw mode" footer. + */ +export function renderRawModeMessages(allMessages: SessionMessage[], mode: string | RawMode): void { + for (const msg of allMessages) { + process.stdout.write("\n"); + process.stdout.write(renderMessageToStdout(msg, mode as RawMode) + "\n\n"); + } + if (allMessages.length > 0) { + process.stdout.write("\n\n"); + process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + } else { + process.stdout.write("\n"); + process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)")); + process.stdout.write("\n\n"); + process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + } +} diff --git a/src/updateCheck.ts b/src/updateCheck.ts index 2aae393..fcd9bfb 100644 --- a/src/updateCheck.ts +++ b/src/updateCheck.ts @@ -1,11 +1,12 @@ -import { spawn } from "child_process"; +import { spawn, type ChildProcess, type SpawnOptions } from "child_process"; import React from "react"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; -import { UpdatePrompt, type UpdatePromptChoice } from "./ui/UpdatePrompt"; +import { UpdatePrompt, type UpdatePromptChoice } from "./ui"; +import { killProcessTree } from "./common/process-tree"; export type PackageInfo = { name: string; @@ -25,6 +26,7 @@ type UpdateState = { const UPDATE_STATE_FILE = "update-check.json"; const NPM_VIEW_TIMEOUT_MS = 5000; const MAX_NPM_VIEW_OUTPUT_CHARS = 64 * 1024; +const TENCENT_MIRROR_REGISTRY = "https://mirrors.cloud.tencent.com/npm/"; export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise<{ installed: boolean }> { const state = readUpdateState(); @@ -48,14 +50,16 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const choice = await promptUpdateChoice({ currentVersion: packageInfo.version, latestVersion: pending.latestVersion, - installCommand + installCommand, }); if (choice === "install") { const ok = await runNpmInstallGlobal(installSpec); if (ok) { writeUpdateState({ ...state, pending: null }); - process.stdout.write(`\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n`); + process.stdout.write( + `\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` + ); } return { installed: ok }; } @@ -94,8 +98,8 @@ export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise currentVersion: packageInfo.version, latestVersion, packageName: packageInfo.name, - checkedAt: new Date().toISOString() - } + checkedAt: new Date().toISOString(), + }, }); } catch { // Update checks must never affect CLI startup or normal operation. @@ -126,7 +130,7 @@ export function getUpdateStatePath(): string { async function promptUpdateChoice({ currentVersion, latestVersion, - installCommand + installCommand, }: { currentVersion: string; latestVersion: string; @@ -149,7 +153,7 @@ async function promptUpdateChoice({ currentVersion, latestVersion, installCommand, - onSelect: handleSelect + onSelect: handleSelect, }), { exitOnCtrlC: false } ); @@ -158,9 +162,8 @@ async function promptUpdateChoice({ async function runNpmInstallGlobal(installSpec: string): Promise { return new Promise((resolve) => { - const child = spawn("npm", ["install", "-g", installSpec], { + const child = spawnNpm(["install", "-g", installSpec], { stdio: "inherit", - shell: process.platform === "win32" }); child.on("error", (error) => { process.stderr.write(`Failed to start npm install: ${error.message}\n`); @@ -178,7 +181,14 @@ async function runNpmInstallGlobal(installSpec: string): Promise { } async function fetchLatestNpmVersion(packageName: string): Promise { - const result = await runNpmViewLatestVersion(packageName, NPM_VIEW_TIMEOUT_MS); + // Try Tencent mirror first for faster access in mainland China. + const mirrorResult = await runNpmViewLatestVersion(packageName, TENCENT_MIRROR_REGISTRY, NPM_VIEW_TIMEOUT_MS); + if (mirrorResult.ok) { + return parseNpmViewVersion(mirrorResult.stdout); + } + + // Fall back to the official npm registry. + const result = await runNpmViewLatestVersion(packageName, undefined, NPM_VIEW_TIMEOUT_MS); if (!result.ok) { return null; } @@ -187,12 +197,16 @@ async function fetchLatestNpmVersion(packageName: string): Promise { return new Promise((resolve) => { - const child = spawn("npm", ["view", packageName, "dist-tags.latest", "--json"], { + const args = ["view", packageName, "dist-tags.latest", "--json"]; + if (registry) { + args.push("--registry", registry); + } + const child = spawnNpm(args, { stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32" }); let stdout = ""; @@ -207,7 +221,11 @@ function runNpmViewLatestVersion( }; const timer = setTimeout(() => { - child.kill(); + if (typeof child.pid === "number") { + killProcessTree(child.pid, "SIGTERM", { killGroupOnNonWindows: false }); + } else { + child.kill(); + } finish({ ok: false }); }, timeoutMs); @@ -226,6 +244,24 @@ function runNpmViewLatestVersion( }); } +function spawnNpm(args: string[], options: SpawnOptions): ChildProcess { + if (process.platform === "win32") { + return spawn(["npm", ...args.map(quoteCmdArg)].join(" "), [], { + ...options, + shell: true, + }); + } + + return spawn("npm", args, { + ...options, + shell: false, + }); +} + +function quoteCmdArg(arg: string): string { + return `"${String(arg).replace(/"/g, '\\"')}"`; +} + export function parseNpmViewVersion(output: string): string | null { const trimmed = output.trim(); if (!trimmed) { @@ -249,8 +285,10 @@ function readUpdateState(): UpdateState { return { pending: parsed.pending ?? null, ignoredVersions: Array.isArray(parsed.ignoredVersions) - ? parsed.ignoredVersions.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : [] + ? parsed.ignoredVersions.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0 + ) + : [], }; } catch { return {}; diff --git a/templates/prompts/init_command.md.ejs b/templates/prompts/init_command.md.ejs new file mode 100644 index 0000000..ec2f2f6 --- /dev/null +++ b/templates/prompts/init_command.md.ejs @@ -0,0 +1,44 @@ +<% if (agentsMdFile == null) { %> +Generate a file named ./AGENTS.md that serves as a contributor guide for this repository. +<% } else { %> +Update <%= agentsMdFile %> that serves as a contributor guide for this repository. +<% } %> +Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. +Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. + +Document Requirements + +- Title the document "Repository Guidelines". +- Use Markdown headings (#, ##, etc.) for structure. +- Keep the document concise. 200-400 words is optimal. +- Keep explanations short, direct, and specific to this repository. +- Provide examples where helpful (commands, directory paths, naming patterns). +- Maintain a professional, instructional tone. + +Recommended Sections + +Project Structure & Module Organization + +- Outline the project structure, including where the source code, tests, and assets are located. + +Build, Test, and Development Commands + +- List key commands for building, testing, and running locally (e.g., npm test, make build). +- Briefly explain what each command does. + +Coding Style & Naming Conventions + +- Specify indentation rules, language-specific style preferences, and naming patterns. +- Include any formatting or linting tools used. + +Testing Guidelines + +- Identify testing frameworks and coverage requirements. +- State test naming conventions and how to run tests. + +Commit & Pull Request Guidelines + +- Summarize commit message conventions found in the project’s Git history. +- Outline pull request requirements (descriptions, linked issues, screenshots, etc.). + +(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. diff --git a/templates/skills/agent-drift-guard.md b/templates/skills/agent-drift-guard.md new file mode 100644 index 0000000..c6711b1 --- /dev/null +++ b/templates/skills/agent-drift-guard.md @@ -0,0 +1,152 @@ +--- +name: agent-drift-guard +description: Detect and correct execution drift while working on user requests. Use when you are actively implementing, debugging, reviewing, or investigating and there is a risk of wandering beyond the user's goal, adding unrequested work, touching live systems, over-exploring, or ignoring repeated user boundary corrections. Especially useful during multi-step coding tasks, production-adjacent requests, ambiguous scopes, and anytime you should self-check whether it is still solving the requested problem. +--- + +# Agent Drift Guard + +Keep execution tightly aligned with the user's actual request. + +## Quick Start + +Run this mental check before substantial work and again whenever the plan expands: + +1. State the user's requested outcome in one sentence. +2. List explicit non-goals or boundaries the user has set. +3. Ask whether the next action directly advances the requested outcome. +4. If not, either cut it or pause to confirm. + +## Drift Signals + +Treat these as warning signs that execution may be drifting: + +- Exploring broadly before opening the most relevant file, command, or artifact. +- Solving adjacent operational issues when the user asked only for code changes. +- Adding extra safeguards, scripts, docs, refactors, or cleanup that the user did not ask for. +- Reframing the task around what seems "better" instead of what was requested. +- Continuing with a broader plan after the user narrows the scope. +- Repeating searches or tool calls without increasing certainty. +- Mixing diagnosis, remediation, and feature work when the user asked for only one of them. +- Touching production-like state, external systems, or live data without explicit permission. + +## Severity Levels + +### Level 1: Mild Drift + +Examples: +- One or two extra exploratory commands. +- Considering a broader solution but not acting on it yet. +- Briefly over-explaining instead of moving the task forward. + +Response: +- Auto-correct silently. +- Narrow to the smallest next action. +- Do not interrupt the user. + +### Level 2: Material Drift + +Examples: +- Planning additional deliverables not requested. +- Writing helper scripts, migrations, docs, or tests outside the asked scope. +- Expanding from code changes into operational fixes. +- Continuing after the user has already corrected the scope once. + +Response: +- Stop and realign internally first. +- If the broader action is avoidable, drop it and continue on scope. +- If the broader action has non-obvious tradeoffs, ask a brief confirmation question. + +### Level 3: Boundary or Risk Violation + +Examples: +- Modifying live systems, production data, external services, or user-owned state without being asked. +- Taking destructive or hard-to-reverse actions outside the requested scope. +- Ignoring repeated user instructions about what not to do. + +Response: +- Pause before acting. +- Surface the exact boundary and ask for confirmation. +- Offer the smallest on-scope option first. + +## Self-Check Loop + +Use this loop during execution: + +### Before the first meaningful action + +Write down mentally: +- Requested outcome +- Allowed scope +- Forbidden scope +- Smallest useful next step + +### After each non-trivial step + +Ask: +- Did this step directly help deliver the requested outcome? +- Did I learn something that changes scope, or only implementation? +- Am I about to do more than the user asked? + +### After a user correction + +Treat the correction as a hard boundary update. + +Then: +- Remove the old broader plan. +- Do not defend the discarded work. +- Continue from the narrowed scope. +- If needed, acknowledge briefly and move on. + +## Decision Rules + +Use these rules in order: + +1. Prefer the most direct artifact first. + - Open the relevant file before scanning the whole repo. + - Inspect the specific failing path before designing a general framework. + +2. Prefer the smallest complete fix. + - Solve the asked problem before improving related systems. + - Avoid bonus work unless it is required for correctness. + +3. Prefer internal correction over user interruption. + - If you can shrink back to scope confidently, do it. + - Ask only when the next step changes deliverables, risk, or ownership. + +4. Treat repeated user constraints as priority signals. + - A repeated instruction means your execution style is currently misaligned. + - Tighten scope immediately. + +5. Separate categories of work. + - Code change, investigation, production remediation, cleanup, and documentation are distinct tasks unless the user explicitly combines them. + +## Good Intervention Style + +When you must pause, keep it short and specific: + +- State the potential drift in one sentence. +- Name the tradeoff or boundary. +- Offer the smallest on-scope option first. + +Example: + +"Quick alignment check: I can keep this to the code fix only, or also add an ops cleanup step. I'll stick to the code fix unless you want both." + +## Anti-Patterns + +Do not: + +- Create cleanup scripts, docs, or side tools just because they seem useful. +- Broaden the task after discovering a neighboring problem. +- Continue with a plan the user has already rejected. +- Justify drift with "best practice" when the user asked for a narrower deliverable. +- Hide extra work inside a larger patch. + +## Final Check Before Responding + +Before sending the final answer, verify: + +- The delivered work matches the requested outcome. +- No extra deliverables were added without confirmation. +- Any assumptions are stated briefly. +- Suggested next steps are optional, not bundled into the completed work. diff --git a/templates/skills/plan-and-execute.md b/templates/skills/plan-and-execute.md new file mode 100644 index 0000000..9fc8bd2 --- /dev/null +++ b/templates/skills/plan-and-execute.md @@ -0,0 +1,246 @@ +--- +name: plan-and-execute +description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. +--- + +# Plan and Execute + +This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. + +## Quick Start + +When you need to work through a multi-step request: + +1. Analyze the requirements and explore enough project context +2. Clarify unclear or ambiguous requirements with AskUserQuestion +3. Create a markdown task list by calling the UpdatePlan tool +4. Execute tasks one by one, updating the tool plan in real time +5. Revise the remaining plan as new context appears + +## Instructions + +### Step 1: Analyze the requirements + +Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. + +If the original requirements are unclear, incomplete, or ambiguous, call the AskUserQuestion tool before creating the task list. Ask only the questions needed to avoid implementing the wrong behavior, and keep each question specific to the decision that affects the plan or acceptance criteria. + +If a required referenced file path is missing, ask for it with AskUserQuestion: + +``` +What is the path to the referenced file? +``` + +Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. + +- What are the main requirements? +- What tasks need to be completed? +- Are there dependencies between tasks? +- What is the complexity level? +- Which files, modules, commands, or tests are relevant? +- What ambiguity would change the implementation or acceptance criteria? + +### Step 2: Create the task list + +Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: + +```json +{ + "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" +} +``` + +Use this markdown format for the `plan` content: + +```markdown +## Task List + +- [ ] Task 1 description +- [ ] Task 2 description +- [ ] Task 3 description +``` + +Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. + +### Step 3: Execute tasks systematically + +For each task in the list: + +1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. +2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` +3. **Execute the task**: Use appropriate tools to complete the work +4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished +5. **Move to next task**: Only ONE task should be in progress at a time + +Important rules: +- Always keep the plan aligned with the latest context before executing the next task +- Always call UpdatePlan BEFORE starting work on a task +- Always call UpdatePlan IMMEDIATELY after completing a task +- Always pass the complete current markdown task list, not a partial diff +- Never work on multiple tasks simultaneously +- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them +- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers + +### Step 4: Handle task breakdown + +If during execution you discover a task is more complex than expected: + +1. Keep the current task as `[>]` +2. Call UpdatePlan with new sub-tasks below it with indentation: + ```markdown + - [>] Main task + - [ ] Sub-task 1 + - [ ] Sub-task 2 + ``` +3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan + +### Step 5: Final verification + +After all tasks are completed (`[x]`): + +1. Review the original requirements to ensure everything is addressed +2. Run any final checks (tests, builds, linting) +3. Call UpdatePlan with every task marked `[x]` +4. Provide a concise completion summary in the final response + +## Task State Symbols + +- `[ ]` - Pending +- `[>]` - In progress +- `[x]` - Completed +- `[!]` - Blocked + +## Examples + +### Example 1: Simple feature request + +**Example requirements:** +```markdown +# 新功能:添加深色模式切换 + +用户应该能够在浅色和深色主题之间切换。 +切换开关应放在设置页面中。 +``` + +**分析后的 UpdatePlan 调用:** +```markdown +## Task List + +- [ ] 在设置页面创建深色模式切换组件 +- [ ] 添加深色模式状态管理(context/store) +- [ ] 实现深色主题的 CSS-in-JS 样式 +- [ ] 更新现有组件以支持主题切换 +- [ ] 运行测试并验证功能 +``` + +**UpdatePlan call during execution:** +```markdown +## Task List + +- [x] 在设置页面创建深色模式切换组件 +- [>] 添加深色模式状态管理(context/store) +- [ ] 实现深色主题的 CSS-in-JS 样式 +- [ ] 更新现有组件以支持主题切换 +- [ ] 运行测试并验证功能 +``` + +### Example 2: Bug fix with investigation + +**Example requirements:** +```markdown +# Fix bug:登录表单提交时崩溃 + +当用户点击提交时,应用崩溃。 +错误信息:"Cannot read property 'email' of undefined" +``` + +**UpdatePlan call after analysis:** +```markdown +## Task List + +- [ ] 在本地复现缺陷 +- [ ] 调查登录表单组件中的错误 +- [ ] 定位 undefined email 属性的根本原因 +- [ ] 实施修复 +- [ ] 添加验证以防止类似问题 +- [ ] 使用各种输入测试修复 +- [ ] 更新错误处理 +``` + +## When to Use This Skill + +Use this Skill when: + +1. **Complex multi-step tasks** - Request requires 3+ distinct steps +2. **Feature implementation** - Building new functionality from requirements +3. **Bug fixing** - Need to investigate, fix, and verify +4. **Refactoring** - Multiple files or components need changes +5. **Detailed requirements** - Specifications need to be translated into concrete tasks +6. **Need progress tracking** - Want visible progress without editing source files + +## When NOT to Use This Skill + +Skip this Skill when: + +1. **Single simple task** - Just one straightforward action needed +2. **Trivial changes** - Quick fixes that don't need planning +3. **Informational requests** - User just wants explanation, not execution +4. **No execution requested** - User only wants brainstorming or a high-level explanation + +## Best Practices + +1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" +2. **Keep tasks atomic**: Each task should be independently completable +3. **Update immediately**: Don't batch status updates, do them in real-time +4. **One task at a time**: Never mark multiple tasks as `[>]` +5. **Handle blockers**: If stuck, create new tasks to resolve the blocker +6. **Verify completion**: Only mark `[x]` when task is fully done + +## Advanced Usage + +### Handling dependencies + +When tasks have dependencies, order them properly: + +```markdown +- [ ] Create database schema +- [ ] Implement API endpoints (depends on schema) +- [ ] Build frontend forms (depends on API) +``` + +### Using sub-tasks + +For complex tasks, break them down: + +```markdown +- [>] Implement authentication system + - [x] Set up JWT library + - [>] Create login endpoint + - [ ] Create logout endpoint + - [ ] Add token refresh logic +``` + +### Adding notes + +Add implementation notes or findings: + +```markdown +- [x] Investigate performance issue + - Note: Found N+1 query in user loader + - Solution: Added dataloader batching +``` + +## Workflow Summary + +1. Analyze the requirements and relevant project context +2. Call AskUserQuestion if the original requirements are unclear or ambiguous +3. Call UpdatePlan with the structured markdown task list +4. Refresh the remaining plan before the first task +5. For each task: + - Update to `[>]` with UpdatePlan + - Execute the task + - Update to `[x]` with UpdatePlan + - Re-evaluate and revise remaining tasks before moving on +6. Call UpdatePlan with all tasks completed and summarize the result + +This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/docs/tools/ask-user-question.md b/templates/tools/ask-user-question.md similarity index 100% rename from docs/tools/ask-user-question.md rename to templates/tools/ask-user-question.md diff --git a/docs/tools/bash.md b/templates/tools/bash.md similarity index 76% rename from docs/tools/bash.md rename to templates/tools/bash.md index cb6cdcc..83027d3 100644 --- a/docs/tools/bash.md +++ b/templates/tools/bash.md @@ -2,10 +2,11 @@ Executes a given bash command. Working directory persists between commands; shell state (everything else) does not. The shell environment is initialized from the user's profile (bash or zsh). +On Windows, Bash runs through Git Bash. Use POSIX commands and quote Windows paths carefully. + IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. IMPORTANT: Before reaching for generic shell pipelines, prefer purpose-built CLI tools when they make the task more accurate, safer, faster, or easier to understand: -- Use `ast-grep` when you need syntax-aware code search or structural rewrites; prefer it over plain text matching for language code. - Use `ripgrep` (`rg`) when you need to search file contents by text or regex across the workspace; prefer it over slower tools like `grep`. - Use `jq` when you need to inspect, filter, or transform JSON output; prefer it over ad-hoc parsing with `sed`, `awk`, or Python one-liners. @@ -27,6 +28,11 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. + - The sideEffects argument is required. Declare the minimum permission scopes the command may need. + - Use `sideEffects: []` only for commands that do not read, write, delete, query Git history, mutate Git history, or access the network, such as `date` or `node --version`. + - Use `*-out-cwd` when the command accesses paths outside the current workspace. For example, `cat /etc/hosts` requires `["read-out-cwd"]`. + - Use `query-git-log` for commands such as `git log`, `git show HEAD`, `git blame`, or history diffs. Use `mutate-git-log` for commands such as `git commit`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, or `git tag`. + - Use `["unknown"]` when you cannot classify the command safely. - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - Always prefer using the dedicated tools for these commands: @@ -59,10 +65,31 @@ Usage notes: "description": { "description": "Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls → \"List files in current directory\"\n- git status → \"Show working tree status\"\n- npm install → \"Install package dependencies\"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name \"*.tmp\" -exec rm {} \\; → \"Find and delete all .tmp files recursively\"\n- git reset --hard origin/main → \"Discard all local changes and match remote main\"\n- curl -s url | jq '.data[]' → \"Fetch JSON from URL and extract data array elements\"", "type": "string" + }, + "sideEffects": { + "description": "Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use [\"unknown\"] when the effects cannot be classified safely.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown" + ] + }, + "uniqueItems": true } }, "required": [ - "command" + "command", + "sideEffects" ], "additionalProperties": false } diff --git a/docs/tools/edit.md b/templates/tools/edit.md similarity index 100% rename from docs/tools/edit.md rename to templates/tools/edit.md diff --git a/docs/tools/read.md b/templates/tools/read.md.ejs similarity index 93% rename from docs/tools/read.md rename to templates/tools/read.md.ejs index 60daa3d..a9c50e5 100644 --- a/docs/tools/read.md +++ b/templates/tools/read.md.ejs @@ -10,7 +10,11 @@ Usage: - Any lines longer than 2000 characters will be truncated - Results are returned using cat -n format, with line numbers starting at 1 - Text reads return a snippet id in metadata. You can pass that snippet id to the Edit tool to constrain replacements to just that read range. +<%_ if (supportsMultimodal) { _%> - This tool allows you to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Deepseek is a multimodal LLM. +<%_ } else { _%> +- This tool can inspect image files, but the current model is not multimodal, so image reads are not presented visually to the model. +<%_ } _%> - This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request. - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations. - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. diff --git a/templates/tools/update-plan.md b/templates/tools/update-plan.md new file mode 100644 index 0000000..0c74b36 --- /dev/null +++ b/templates/tools/update-plan.md @@ -0,0 +1,33 @@ +## UpdatePlan + +Updates the current task plan and progress display. + +Usage: +- Use this tool for non-trivial multi-step tasks when a task list helps track execution progress. +- Pass the complete current task list every time. The latest call replaces the previous visible plan. +- The `plan` argument is a markdown string, not an array of step objects. If the requirement is in Chinese, then use Chinese for the markdown as well. +- Keep exactly one task marked `[>]` while work is in progress. +- Update the plan before starting a task, immediately after completing a task, and whenever tasks are split, merged, reordered, blocked, or changed. +- Before executing the first task and after completing each task, re-evaluate the latest conversation and project context, then revise the remaining plan if needed. +- Remove tasks that are no longer relevant, and add newly discovered follow-up tasks before working on them. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "plan": { + "description": "The complete markdown task list to display as the latest plan state.", + "type": "string" + }, + "explanation": { + "description": "Optional short reason for changing the plan.", + "type": "string" + } + }, + "required": [ + "plan" + ], + "additionalProperties": false +} +``` diff --git a/docs/tools/web-search.md b/templates/tools/web-search.md similarity index 100% rename from docs/tools/web-search.md rename to templates/tools/web-search.md diff --git a/docs/tools/write.md b/templates/tools/write.md similarity index 100% rename from docs/tools/write.md rename to templates/tools/write.md diff --git a/tsconfig.json b/tsconfig.json index ef4f50d..24a6a1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { "target": "ES2022", - "module": "commonjs", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", "lib": ["ES2022"], "jsx": "react-jsx", "strict": true,