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/.lintstagedrc b/.lintstagedrc index ef49282..4754b68 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,5 +1,5 @@ { - "*.{ts,tsx,js,mjs,cjs,ejs,jsx}": [ + "*.{ts,tsx,js,mjs,cjs,jsx}": [ "eslint --fix", "prettier --write" ], 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 ea5dcde..2643756 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ -# Deep Code CLI +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

-[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 +[![][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 集成。 ## 安装 @@ -30,6 +46,8 @@ npm install -g @vegamo/deepcode-cli 配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + ## 主要功能 ### **Skills** @@ -43,20 +61,29 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 - 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 -## 快捷键 - -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `Ctrl+D` | 退出 | +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 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` | 退出 | ## 支持的模型 @@ -77,12 +104,28 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g +编写一个调用 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 为例: @@ -98,6 +141,31 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 } ``` +## 贡献 + +欢迎贡献代码!以下是参与方式: + +```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) @@ -113,3 +181,22 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 - 在 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_cn.md b/README_cn.md deleted file mode 100644 index ea5dcde..0000000 --- a/README_cn.md +++ /dev/null @@ -1,115 +0,0 @@ -# Deep Code CLI - -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 - -## 安装 - -```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) 共享,无需重复配置。 - -## 主要功能 - -### **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)和思考强度控制。 - -## 快捷键 - -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `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` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g - -### 怎样启用联网搜索功能? - -Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli - -### 是否支持 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 -} -``` - -## 获取帮助 - -- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) - -## 协议 - -- MIT - -## 支持我们 - -如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: - -- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 diff --git a/README_en.md b/README_en.md deleted file mode 100644 index dc49ae0..0000000 --- a/README_en.md +++ /dev/null @@ -1,114 +0,0 @@ -# Deep Code CLI - -[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, and Agent Skills. - -## 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. - -## 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 Thinking Effort Control. - -## Keyboard Shortcuts - -| 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 | - -## 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, refer to: https://binfer.net/share/jby5xnc-so6g - -### 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 -} -``` - -## 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 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/package-lock.json b/package-lock.json index 2528b4c..dfa3fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.18", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.18", + "version": "0.1.25", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -18,6 +18,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", + "undici": "^7.25.0", "zod": "^4.4.3" }, "bin": { @@ -32,6 +33,7 @@ "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", @@ -40,7 +42,7 @@ "typescript-eslint": "^8.59.2" }, "engines": { - "node": ">=18.17.0" + "node": ">=22" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -2249,6 +2251,24 @@ "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", @@ -2262,6 +2282,45 @@ "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", @@ -2911,6 +2970,16 @@ "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/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -3060,6 +3129,33 @@ "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": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -4001,6 +4097,15 @@ "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": "7.19.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", diff --git a/package.json b/package.json index 35a5cab..71c171c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.18", + "version": "0.1.25", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", @@ -15,13 +15,14 @@ "main": "./dist/cli.js", "files": [ "dist/cli.js", - "docs/tools/**", - "docs/prompts/**", + "templates/tools/**", + "templates/prompts/**", + "templates/skills/**", "README.md", "LICENSE" ], "engines": { - "node": ">=18.17.0" + "node": ">=22" }, "scripts": { "typecheck": "tsc -p ./ --noEmit", @@ -32,7 +33,7 @@ "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": "tsx --test src/tests/*.test.ts", + "test": "node src/tests/run-tests.mjs", "test:single": "tsx --test", "prepack": "npm run build", "prepare": "husky" @@ -47,6 +48,7 @@ "ink-gradient": "^4.0.0", "openai": "^6.35.0", "react": "^19.2.5", + "undici": "^7.25.0", "zod": "^4.4.3" }, "devDependencies": { @@ -58,6 +60,7 @@ "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", diff --git a/src/cli.tsx b/src/cli.tsx index f04125f..c3876ae 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,8 +1,8 @@ import React from "react"; import { render } from "ink"; -import { App } from "./ui"; -import { setShellIfWindows } from "./tools/shell-utils"; +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(); @@ -18,12 +18,15 @@ 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", " ./.agents/skills/*/SKILL.md Project-level skills", " ./.deepcode/skills/*/SKILL.md Legacy project-level skills", @@ -38,9 +41,15 @@ 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", ].join("\n") + "\n" @@ -48,6 +57,15 @@ if (args.includes("--help") || args.includes("-h")) { 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(); @@ -64,19 +82,29 @@ async function main(): Promise { const restartRef: { current: (() => void) | null } = { current: null }; function startApp(): void { + let restarting = false; + const appInitialPrompt = initialPrompt; + initialPrompt = undefined; const inkInstance = render( - restartRef.current?.()} />, + restartRef.current?.()} + />, { exitOnCtrlC: false } ); restartRef.current = () => { + restarting = true; process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); inkInstance.unmount(); startApp(); }; inkInstance.waitUntilExit().then(() => { - if (!restartRef.current) { + if (!restarting) { + restartRef.current = null; process.exit(0); } }); 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/debug-logger.ts b/src/common/debug-logger.ts similarity index 100% rename from src/debug-logger.ts rename to src/common/debug-logger.ts diff --git a/src/error-logger.ts b/src/common/error-logger.ts similarity index 100% rename from src/error-logger.ts rename to src/common/error-logger.ts 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 100% rename from src/tools/file-utils.ts rename to src/common/file-utils.ts 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 2fdc9fa..d1b541b 100644 --- a/src/notify.ts +++ b/src/common/notify.ts @@ -16,18 +16,49 @@ export function formatDurationSeconds(durationMs: number): string { return String(Math.floor(safeMs / 1000)); } -export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { +export type NotifyContext = { + status?: string; + failReason?: string; + body?: string; + title?: string; +}; + +export function buildNotifyEnv( + durationMs: number, + baseEnv: NodeJS.ProcessEnv = process.env, + context: NotifyContext = {} +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv, 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) { @@ -37,7 +68,7 @@ export function launchNotifyScript( const options = { cwd: workingDirectory, detached: process.platform !== "win32", - env: buildNotifyEnv(durationMs), + env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context), stdio: "ignore" as const, }; 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/openai-thinking.ts b/src/common/openai-thinking.ts similarity index 91% rename from src/openai-thinking.ts rename to src/common/openai-thinking.ts index 0726152..1585cbd 100644 --- a/src/openai-thinking.ts +++ b/src/common/openai-thinking.ts @@ -1,4 +1,4 @@ -import type { ReasoningEffort } from "./settings"; +import type { ReasoningEffort } from "../settings"; type ThinkingConfig = { type: "enabled" | "disabled"; 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 99% rename from src/tools/runtime.ts rename to src/common/runtime.ts index 9a4620b..b1195d8 100644 --- a/src/tools/runtime.ts +++ b/src/common/runtime.ts @@ -1,5 +1,5 @@ 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 }; diff --git a/src/tools/shell-utils.ts b/src/common/shell-utils.ts similarity index 60% rename from src/tools/shell-utils.ts rename to src/common/shell-utils.ts index 12ce952..1dcec25 100644 --- a/src/tools/shell-utils.ts +++ b/src/common/shell-utils.ts @@ -5,12 +5,19 @@ import * as path from "path"; import * as pathWin32 from "path/win32"; const WINDOWS_GIT_LOCATIONS = ["C:\\Program Files\\Git\\cmd\\git.exe", "C:\\Program Files (x86)\\Git\\cmd\\git.exe"]; +const WINDOWS_BASH_LOCATIONS = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"]; const NUL_REDIRECT_REGEX = /(\d?&?>+\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; @@ -23,16 +30,30 @@ export function findGitBashPath(): string { return cachedGitBashPath; } - for (const gitPath of findAllWindowsExecutableCandidates("git")) { - const bashPath = pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"); - if (fs.existsSync(bashPath)) { - cachedGitBashPath = bashPath; - return bashPath; - } + 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 and ensure git.exe is available in PATH." + "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 ); } @@ -128,9 +149,10 @@ export function toNativeCwd(shellCwd: string): string { return posixPathToWindowsPath(shellCwd); } -export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { +export function buildShellEnv(shellPath: string, extraEnv: Record = {}): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, + ...extraEnv, SHELL: shellPath, GIT_EDITOR: "true", }; @@ -145,7 +167,8 @@ export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { } function findAllWindowsExecutableCandidates(executable: string): string[] { - const extraCandidates = executable === "git" ? WINDOWS_GIT_LOCATIONS : []; + const extraCandidates = + executable === "git" ? WINDOWS_GIT_LOCATIONS : executable === "bash" ? WINDOWS_BASH_LOCATIONS : []; try { const output = execFileSync("where.exe", [executable], { @@ -153,18 +176,67 @@ function findAllWindowsExecutableCandidates(executable: string): string[] { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, }); - return filterWindowsExecutableCandidates([ - ...output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ...extraCandidates, - ]); + 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(); diff --git a/src/tools/state.ts b/src/common/state.ts similarity index 76% rename from src/tools/state.ts rename to src/common/state.ts index 7816573..add27f3 100644 --- a/src/tools/state.ts +++ b/src/common/state.ts @@ -7,6 +7,7 @@ export type FileState = { filePath: string; content: string; timestamp: number; + version?: number; offset?: number; limit?: number; isPartialView?: boolean; @@ -20,11 +21,13 @@ 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, platform: NodeJS.Platform = process.platform): string { const nativePath = normalizeNativeFilePath(filePath, platform); @@ -57,7 +60,11 @@ function isGitBashAbsolutePath(filePath: string): boolean { return /^\/[A-Za-z](?:\/|$)/.test(filePath) || /^\/cygdrive\/[A-Za-z](?:\/|$)/.test(filePath); } -export function recordFileState(sessionId: string, state: FileState): void { +export function recordFileState( + sessionId: string, + state: FileState, + options: { incrementVersion?: boolean } = {} +): void { if (!sessionId || !state.filePath) { return; } @@ -69,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, + version: nextVersion, }); } @@ -108,6 +119,22 @@ 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" @@ -134,6 +161,7 @@ export function createSnippet( startLine, endLine, preview, + fileVersion: getFileVersion(sessionId, filePath), }; let snippets = snippetsBySession.get(sessionId); @@ -151,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/prompt.ts b/src/prompt.ts index e1def61..ba9bf23 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -3,163 +3,10 @@ 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"; -import { findGitBashPath, resolveShellPath } from "./tools/shell-utils"; - -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. @@ -243,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; }; -function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): string { - const toolsDir = path.join(extensionRoot, "docs", "tools"); +const DEFAULT_SKILL_TEMPLATES = ["agent-drift-guard.md", "plan-and-execute.md"]; + +function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { + 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 ""; } @@ -274,10 +128,46 @@ function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): 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)}`; + return basePrompt; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -296,7 +186,7 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { 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" } : {}; @@ -310,12 +200,15 @@ function getRuntimeContext(projectRoot: string): string { ...shellModeOpts, ...runtimeVersions, "command installed": { - "ast-grep": checkToolInstalled("ast-grep"), 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)} \`\`\``; } @@ -419,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", @@ -438,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, }, }, @@ -496,6 +410,30 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, }, }, + { + type: "function", + function: { + name: "UpdatePlan", + description: + "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: { @@ -610,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 2267bb0..a9fc39e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -6,16 +6,58 @@ 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 { logApiError } from "./error-logger"; -import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; +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; @@ -63,11 +105,34 @@ 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); + 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; + } + + const usagePerModel = { ...(current ?? {}) }; + const modelName = model.trim() || "unknown"; + usagePerModel[modelName] = accumulateUsage(usagePerModel[modelName] ?? null, usageWithRequestCount(next))!; + return usagePerModel; } function getExtensionRoot(): string { @@ -79,7 +144,7 @@ function getExtensionRoot(): string { 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; } @@ -87,7 +152,41 @@ function getTotalTokens(usage: unknown | null | undefined): number { return typeof totalTokens === "number" ? totalTokens : 0; } -export type SessionStatus = "failed" | "pending" | "processing" | "waiting_for_user" | "completed" | "interrupted"; +export type SessionStatus = + | "failed" + | "pending" + | "processing" + | "waiting_for_user" + | "completed" + | "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; @@ -98,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 = { @@ -119,7 +220,10 @@ export type MessageMeta = { resultMd?: string; asThinking?: boolean; isSummary?: boolean; + isModelChange?: boolean; skill?: SkillInfo; + permissions?: MessageToolPermission[]; + userPrompt?: UserPromptContent; }; export type SessionMessage = { @@ -135,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 = { @@ -153,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 = { @@ -172,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; @@ -188,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 { @@ -263,7 +421,7 @@ export class SessionManager { debug?: ChatCompletionDebugOptions ): Promise<{ choices?: Array<{ message?: Record }>; - usage?: unknown; + usage?: ModelUsage | null; }> { const requestId = crypto.randomUUID(); const startedAt = new Date().toISOString(); @@ -332,13 +490,13 @@ export class SessionManager { request: streamRequest, response, }); - return response as { choices?: Array<{ message?: Record }>; usage?: unknown }; + return response as { choices?: Array<{ message?: Record }>; usage?: ModelUsage | null }; } let content = ""; let reasoningContent = ""; let refusal: string | null = null; - let usage: unknown = null; + let usage: ModelUsage | null = null; const responseChunks: unknown[] = []; const toolCallsByIndex = new Map< number, @@ -363,7 +521,7 @@ export class SessionManager { 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 : []; @@ -456,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; @@ -580,7 +739,7 @@ The candidate skills are as follows:\n\n`; if (!fs.existsSync(root)) { return []; } - let entries: fs.Dirent[] = []; + let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { @@ -754,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; @@ -779,23 +944,9 @@ The candidate skills are as follows:\n\n`; this.reportNewPrompt(); const signal = controller?.signal; this.throwIfAborted(signal); - this.applyInitCommandPrompt(userPrompt); - 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 = { @@ -808,6 +959,7 @@ The candidate skills are as follows:\n\n`; status: "pending", failReason: null, usage: null, + usagePerModel: null, activeTokens: 0, createTime: now, updateTime: now, @@ -829,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) { @@ -870,12 +1046,15 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); - this.applyInitCommandPrompt(userPrompt); + 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, + askPermissions: undefined, updateTime: now, })); @@ -884,8 +1063,24 @@ ${skillMd} return; } + 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 }); @@ -901,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) { @@ -923,9 +1115,22 @@ ${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, debugLogEnabled, notify } = + const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); const now = new Date().toISOString(); @@ -939,12 +1144,12 @@ ${skillMd} this.onAssistantMessage( this.buildAssistantMessage( sessionId, - "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", + "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; } @@ -956,7 +1161,7 @@ ${skillMd} failReason: "interrupted", updateTime: now, })); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); return; } @@ -982,6 +1187,27 @@ ${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( @@ -994,14 +1220,14 @@ ${skillMd} 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()), + tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), ...thinkingOptions, }, { signal: sessionController.signal }, @@ -1018,7 +1244,7 @@ ${skillMd} 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; @@ -1028,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; } @@ -1041,7 +1302,6 @@ ${skillMd} return; } - const responseUsage = response.usage ?? null; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -1049,9 +1309,11 @@ ${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", failReason: refusal ? refusal : entry.failReason, + askPermissions: undefined, updateTime: new Date().toISOString(), })); @@ -1098,7 +1360,7 @@ ${skillMd} if (this.sessionControllers.get(sessionId) === sessionController) { this.sessionControllers.delete(sessionId); } - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); } } @@ -1158,6 +1420,7 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), activeTokens: getTotalTokens(responseUsage), updateTime: now, })); @@ -1185,8 +1448,9 @@ ${skillMd} this.saveSessionMessages(sessionId, sessionMessages); } - private getPromptToolOptions(): { webSearchEnabled: boolean } { + private getPromptToolOptions(): { model: string; webSearchEnabled: boolean } { return { + model: this.getResolvedSettings().model, webSearchEnabled: true, }; } @@ -1197,6 +1461,9 @@ ${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: { @@ -1204,19 +1471,10 @@ ${skillMd} Token: machineId, }, 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 { @@ -1237,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); @@ -1280,6 +1533,51 @@ ${skillMd} 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; @@ -1290,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)) { @@ -1310,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; @@ -1348,6 +1723,74 @@ ${skillMd} return { projectCode, projectDir, sessionsIndexPath }; } + private getFileHistory(): GitFileHistory { + return new GitFileHistory(this.projectRoot, this.getFileHistoryGitDir()); + } + + private getFileHistoryGitDir(): string { + const { projectDir } = this.getProjectStorage(); + return path.join(projectDir, "file-history", ".git"); + } + + private ensureFileHistorySession(sessionId: string): string | undefined { + return this.getFileHistory().ensureSession(sessionId); + } + + 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); + } + } + + 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 }); @@ -1458,18 +1901,13 @@ ${skillMd} visible: true, createTime: now, updateTime: now, + meta: { userPrompt: this.cloneUserPromptForMeta(prompt) }, + checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } - private applyInitCommandPrompt(userPrompt: UserPromptContent): void { - if (userPrompt.text !== "/init") { - return; - } - userPrompt.text = this.renderInitCommandPrompt(); - } - private renderInitCommandPrompt(): string { - const templatePath = path.join(getExtensionRoot(), "docs", "prompts", "init_command.md.ejs"); + const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); return ejs.render(template, { agentsMdFile: this.getEffectiveProjectAgentsMdFile(), @@ -1526,7 +1964,13 @@ ${skillMd} return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); } - private buildSystemMessage(sessionId: string, content: string, contentParams: unknown | null = null): SessionMessage { + private buildSystemMessage( + sessionId: string, + content: string, + contentParams: unknown | null = null, + visible = false, + meta?: MessageMeta + ): SessionMessage { const now = new Date().toISOString(); return { id: crypto.randomUUID(), @@ -1536,9 +1980,10 @@ ${skillMd} contentParams, messageParams: null, compacted: false, - visible: false, + visible, createTime: now, updateTime: now, + meta, }; } @@ -1590,6 +2035,33 @@ ${skillMd} }; } + 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, @@ -1619,12 +2091,39 @@ ${skillMd} }; } - private async appendToolMessages(sessionId: string, toolCalls: unknown[]): Promise<{ waitingForUser: boolean }> { - const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { + private async appendToolMessages( + sessionId: string, + toolCalls: unknown[], + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } = {} + ): Promise<{ waitingForUser: boolean }> { + const hooks: ToolExecutionHooks = { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), + 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 }; } @@ -1655,7 +2154,77 @@ ${skillMd} return { waitingForUser }; } - private buildOpenAIMessages(messages: SessionMessage[], thinkingEnabled: boolean): ChatCompletionMessageParam[] { + 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[] { const activeMessages = messages.filter((message) => !message.compacted); const toolPairings = this.pairToolMessages(activeMessages); const openAIMessages: ChatCompletionMessageParam[] = []; @@ -1666,7 +2235,7 @@ ${skillMd} continue; } - openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled)); + openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled, model)); const toolCalls = this.getAssistantToolCalls(message); if (toolCalls.length === 0) { @@ -1681,7 +2250,9 @@ ${skillMd} const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); if (pairedToolIndex != null) { - openAIMessages.push(this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled)); + openAIMessages.push( + this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled, model) + ); continue; } @@ -1692,10 +2263,15 @@ ${skillMd} return openAIMessages; } - private sessionMessageToOpenAIMessage(message: SessionMessage, thinkingEnabled: boolean): ChatCompletionMessageParam { + private sessionMessageToOpenAIMessage( + message: SessionMessage, + thinkingEnabled: boolean, + model: string + ): ChatCompletionMessageParam { + const content = this.renderOpenAIMessageContent(message); const base: ChatCompletionMessageParam = { role: message.role, - content: message.content ?? "", + content, } as ChatCompletionMessageParam; const messageParams = message.messageParams as @@ -1718,23 +2294,30 @@ ${skillMd} if ((message.role === "user" || message.role === "system") && message.contentParams) { const contentParts: ChatCompletionContentPart[] = []; - if (message.content) { - contentParts.push({ type: "text", text: message.content }); + if (content) { + contentParts.push({ type: "text", text: 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 part = param as ChatCompletionContentPart; + if (part && (part.type !== "image_url" || supportsMultimodal(model))) { + contentParts.push(part); } } - const contentValue: string | ChatCompletionContentPart[] = - contentParts.length > 0 ? contentParts : (message.content ?? ""); + 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(); @@ -1765,6 +2348,25 @@ ${skillMd} 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, @@ -1893,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]; @@ -1950,7 +2556,12 @@ ${skillMd} } } - private maybeNotifyTaskCompletion(sessionId: string, notifyCommand: string | undefined, startedAt: number): void { + private maybeNotifyTaskCompletion( + sessionId: string, + notifyCommand: string | undefined, + startedAt: number, + configuredEnv: Record = {} + ): void { if (!notifyCommand) { return; } @@ -1960,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 { @@ -1978,6 +2605,7 @@ ${skillMd} 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)); @@ -1989,7 +2617,58 @@ ${skillMd} }); } - private getProcessIds(processes: Map | null): number[] { + private setSessionProcessTimeoutControl( + sessionId: string, + processId: string | number, + control: ProcessTimeoutControl | null + ): void { + const key = this.getProcessControlKey(sessionId, processId); + if (!control) { + this.processTimeoutControls.delete(key); + return; + } + + this.processTimeoutControls.set(key, control); + this.updateSessionProcessTimeout(sessionId, processId, control.getInfo()); + } + + 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; + } + processes.set(pid, { + ...processInfo, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }); + return { + ...entry, + processes, + updateTime: now, + }; + }); + } + + private buildBashTimeoutAdjustment(processId: string, info: ProcessTimeoutInfo): BashTimeoutAdjustment { + return { + processId, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }; + } + + private getProcessControlKey(sessionId: string, processId: string | number): string { + return `${sessionId}:${String(processId)}`; + } + + private getProcessIds(processes: Map | null): number[] { if (!processes) { return []; } @@ -2022,18 +2701,6 @@ ${skillMd} ); } - 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) : {}; return { @@ -2045,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), + askPermissions: normalizeAskPermissions(value.askPermissions), }; } @@ -2060,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; @@ -2080,22 +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 { + 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 ffbadf0..e0b1776 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,24 +1,57 @@ -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; @@ -27,42 +60,313 @@ export type ResolvedDeepcodingSettings = { 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 webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.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 = + 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), - debugLogEnabled: settings?.debugLogEnabled === true, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, notify: notify || 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/clipboard.test.ts b/src/tests/clipboard.test.ts index 022b2f8..dbe9ff9 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -36,40 +36,44 @@ test("readClipboardImage returns null when no clipboard helpers are installed", 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 moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; - const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; + 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 index 1084b01..7b1aad4 100644 --- a/src/tests/debug-logger.test.ts +++ b/src/tests/debug-logger.test.ts @@ -3,7 +3,7 @@ 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 "../debug-logger"; +import { getDebugLogPath, logOpenAIChatCompletionDebug } from "../common/debug-logger"; test("debug logger appends full entries without rotation", () => { const originalHome = process.env.HOME; 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 index 9cff34b..5ea4b57 100644 --- a/src/tests/exitSummary.test.ts +++ b/src/tests/exitSummary.test.ts @@ -1,22 +1,23 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildExitSummaryText } from "../ui"; -import type { SessionEntry, SessionMessage } from "../session"; +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({ - prompt_tokens: 11_966, - completion_tokens: 236, - total_tokens: 12_202, - prompt_tokens_details: { cached_tokens: 11_776 }, - completion_tokens_details: { reasoning_tokens: 144 }, + 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, + }, }), - messages: [buildAssistantMessage("assistant-1"), buildAssistantMessage("assistant-2")], - model: "mimo-v2.5-pro", }) ); @@ -33,7 +34,63 @@ test("buildExitSummaryText only shows Goodbye and model usage with cached tokens assert.doesNotMatch(summary, /Reasoning Tokens/); }); -function buildSession(usage: unknown): SessionEntry { +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, @@ -44,24 +101,10 @@ function buildSession(usage: unknown): SessionEntry { status: "completed", failReason: null, usage, + usagePerModel, activeTokens: 0, createTime: "2026-01-01T00:00:00.000Z", updateTime: "2026-01-01T00:00:01.000Z", processes: null, }; } - -function buildAssistantMessage(id: string): SessionMessage { - return { - id, - sessionId: "session-1", - 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", - }; -} 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/markdown.test.ts b/src/tests/markdown.test.ts index a0127fc..bc5d33c 100644 --- a/src/tests/markdown.test.ts +++ b/src/tests/markdown.test.ts @@ -1,11 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { renderMarkdown } from "../ui"; +import { renderMarkdown, renderMarkdownSegments } from "../ui"; function stripAnsi(text: string): string { 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(""), ""); }); @@ -38,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 fef4bc3..b806dbd 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -1,7 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { MessageView, parseDiffPreview } from "../ui"; +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( @@ -25,45 +33,237 @@ test("parseDiffPreview keeps nonstandard context lines", () => { 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" + ); +}); -function buildAssistantMessage(overrides: Partial): SessionMessage { +// --- renderMessageToStdout tests --- + +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 2f22c0b..78d8a00 100644 --- a/src/tests/openai-thinking.test.ts +++ b/src/tests/openai-thinking.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildThinkingRequestOptions } from "../openai-thinking"; +import { buildThinkingRequestOptions } from "../common/openai-thinking"; test("buildThinkingRequestOptions explicitly disables thinking", () => { assert.deepEqual(buildThinkingRequestOptions(false, "https://api.deepseek.com"), { 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 0a2e5da..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, @@ -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 5ab58f1..4f8b4d9 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -12,13 +12,30 @@ import { formatImageAttachmentStatus, formatSelectedSkillsStatus, getPromptCursorPlacement, + getPromptReturnKeyAction, isClearImageAttachmentsShortcut, parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, renderBufferWithCursor, + buildInitPromptSubmission, + buildPromptDraftFromSessionMessage, + dispatchTerminalInput, + disableTerminalExtendedKeys, + enableTerminalExtendedKeys, + EMPTY_BUFFER, + insertText, + backspace, } from "../ui"; -import type { SkillInfo } from "../session"; +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"); @@ -60,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"); @@ -68,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"); @@ -84,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"); @@ -91,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" }; 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 de311ea..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(); @@ -33,7 +51,7 @@ test("SessionManager preserves structured system content when building OpenAI me model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -58,7 +76,7 @@ test("SessionManager preserves structured system content when building OpenAI me }, ]; - 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; }>; @@ -74,6 +92,48 @@ test("SessionManager preserves structured system content when building OpenAI me ]); }); +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(), @@ -82,7 +142,7 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -111,7 +171,7 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", 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; }>; @@ -126,7 +186,7 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -154,10 +214,10 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten }, ]; - 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; }>; @@ -173,7 +233,7 @@ test("SessionManager replays normal assistant messages with reasoning content in model: "test-model", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -193,10 +253,10 @@ test("SessionManager replays normal assistant messages with reasoning content in }, ]; - 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; }>; @@ -207,7 +267,7 @@ test("SessionManager replays normal assistant messages with reasoning content in 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); @@ -233,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 }); @@ -285,7 +359,7 @@ test("SessionManager marks skills loaded from existing session messages", async 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-"); - process.env.HOME = home; + setHomeDir(home); const userSkillDir = path.join(home, ".agents", "skills", "shared"); fs.mkdirSync(userSkillDir, { recursive: true }); @@ -322,10 +396,247 @@ test("SessionManager lists project skills from .agents with legacy .deepcode com assert.equal(sharedSkill?.description, "Project .agents skill"); }); -test("createSession expands /init with the active .deepcode project AGENTS path", async () => { +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-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); @@ -338,20 +649,60 @@ test("createSession expands /init with the active .deepcode project AGENTS path" 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.match(userMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); - assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); + 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("replySession expands /init with the active root project AGENTS path", async () => { +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-"); - process.env.HOME = 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"); @@ -361,16 +712,24 @@ test("replySession expands /init with the active root project AGENTS path", asyn const sessionId = await manager.createSession({ text: "first prompt" }); await manager.replySession(sessionId, { text: "/init" }); - const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user"); + 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.match(replyMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.equal(replyMessage?.content, "/init"); + assert.match(openAIReplyMessage?.content ?? "", /Update \.\/AGENTS\.md/); }); -test("createSession expands /init as generate when no project AGENTS file is effective", async () => { +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-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); @@ -380,16 +739,23 @@ test("createSession expands /init as generate when no project AGENTS file is eff (manager as any).activateSession = async () => {}; const sessionId = await manager.createSession({ text: "/init" }); - const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + 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.match(userMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); - assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); + 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) => { @@ -414,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"); }); @@ -421,7 +788,7 @@ 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) => { @@ -432,27 +799,674 @@ test("replySession reports a new prompt with the machineId token", async () => { } as Response; }) as typeof fetch; - const manager = createSessionManager(workspace, "machine-id-456"); - (manager as any).activateSession = async () => {}; + const manager = createSessionManager(workspace, "machine-id-456"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await flushPromises(); + fetchCalls.length = 0; + + await manager.replySession(sessionId, { text: "second prompt" }); + await flushPromises(); + + 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("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); + + 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-failure"); + (manager as any).activateSession = async () => {}; + + await manager.createSession({ text: "hello world" }); + await flushPromises(); + + assert.deepEqual(warnings, []); +}); + +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"); + } +); + +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 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 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" }); - await flushPromises(); - fetchCalls.length = 0; + 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: "second prompt" }); - await flushPromises(); + await manager.replySession(sessionId, { + text: "Do not write outside the workspace.", + permissions: [{ toolCallId: "call-write", permission: "deny" }], + }); - 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.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); - assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); + 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-"); - process.env.HOME = home; + setHomeDir(home); globalThis.fetch = (async () => ({ @@ -507,7 +1521,11 @@ test("buildOpenAIMessages inserts interrupted results for missing tool messages" ) as SessionMessage; const userMessage = buildTestMessage("user-after-tool-call", "session-1", "user", "continue"); - const openAIMessages = (manager as any).buildOpenAIMessages([assistantMessage, userMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, userMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string; @@ -555,7 +1573,8 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, successToolMessage, interruptedToolMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); @@ -599,7 +1618,8 @@ test("buildOpenAIMessages prefers a later real tool result over an earlier inter const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, interruptedToolMessage, successToolMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); @@ -618,7 +1638,11 @@ test("buildOpenAIMessages ignores orphan tool messages", () => { { name: "bash", arguments: '{"command":"echo orphan"}' } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([userMessage, orphanToolMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [userMessage, orphanToolMessage], + false, + "test-model" + ) as Array<{ role: string; }>; @@ -652,7 +1676,8 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, userMessage, toolMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string }>; assert.deepEqual( @@ -697,7 +1722,8 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, firstToolMessage, secondToolMessage, userMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; assert.deepEqual( @@ -735,7 +1761,11 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { { name: "bash", arguments: '{"command":"false"}' } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([assistantMessage, failedToolMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, failedToolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string; @@ -750,6 +1780,107 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { 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( @@ -791,7 +1922,8 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag const openAIMessages = (manager as any).buildOpenAIMessages( [assistantMessage, orphanToolMessage, pairedToolMessage, duplicateToolMessage, userMessage], - false + false, + "test-model" ) as Array<{ role: string; content: string; tool_call_id?: string }>; const toolMessages = openAIMessages.filter((message) => message.role === "tool"); @@ -836,7 +1968,11 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista "" ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([earlyToolMessage, assistantMessage], false) as Array<{ + const openAIMessages = (manager as any).buildOpenAIMessages( + [earlyToolMessage, assistantMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string; @@ -854,7 +1990,7 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista 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; + setHomeDir(home); const responses = [ createChatResponse("first", { @@ -883,6 +2019,7 @@ test("SessionManager accumulates response usage while active tokens track the la 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); @@ -891,12 +2028,81 @@ 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", { @@ -924,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; @@ -971,7 +2182,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onLlmStreamProgress: (progress) => { @@ -997,10 +2208,10 @@ 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 }); @@ -1026,13 +2237,19 @@ test("SessionManager cancels skill matching before a session is created", async 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 = { @@ -1057,7 +2274,7 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onSessionEntryUpdated: (entry) => { @@ -1076,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, @@ -1086,7 +2575,50 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa thinkingEnabled: false, machineId, }), - getResolvedSettings: () => ({}), + 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: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -1113,7 +2645,43 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + 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({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model", permissions }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -1128,7 +2696,7 @@ function createMockedClientSessionManagerWithClient(projectRoot: string, client: baseURL: "https://api.deepseek.com", thinkingEnabled: false, }), - getResolvedSettings: () => ({}), + getResolvedSettings: () => ({ model: "test-model" }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, }); @@ -1175,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 e3bf51c..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"; +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 15f2ae0..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( @@ -20,7 +28,8 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v3.2"); @@ -33,25 +42,190 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool 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", - } + }, + 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 ignores removed legacy env.THINKING", () => { + const resolved = resolveSettings( + { + env: { + THINKING: "enabled", + }, + }, + { + model: "default-model", + 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( { @@ -62,7 +236,8 @@ test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, true); @@ -74,7 +249,8 @@ test("resolveSettings applies thinking defaults to the fallback model", () => { { model: "deepseek-v4-pro", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.model, "deepseek-v4-pro"); @@ -91,7 +267,8 @@ test("resolveSettings keeps thinking mode off by default for other models", () = { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, false); @@ -108,7 +285,8 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults { model: "default-model", baseURL: "https://default.example.com", - } + }, + TEST_PROCESS_ENV ); assert.equal(resolved.thinkingEnabled, false); @@ -122,56 +300,220 @@ test("resolveSettings defaults invalid reasoning effort to max", () => { { model: "default-model", 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("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 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"); +}); - const spawnProcess: NotifySpawn = (command, args, options) => { - calls.push({ command, args, options: { cwd: options.cwd, env: options.env } }); +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); +}); - return { - once(event, listener) { - if (event === "error" && calls.length === 1) { - listener({ code: "EACCES" } as NodeJS.ErrnoException); - } - return this; - }, - unref() { - return undefined; - }, - }; +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); +}); + +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 index 7d19357..50a71f4 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -4,10 +4,11 @@ import { buildDisableExtglobCommand, getShellKind, posixPathToWindowsPath, + resolveWindowsGitBashPath, rewriteWindowsNullRedirect, windowsPathToPosixPath, -} from "../tools/shell-utils"; -import { isAbsoluteFilePath, normalizeFilePath } from "../tools/state"; +} 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"); @@ -38,6 +39,56 @@ test("Shell kind detection supports Windows bash.exe paths", () => { 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"), diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 6dcc840..30d77ee 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -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", "init", "resume", "exit"]); + assert.deepEqual(builtinNames, [ + "skills", + "model", + "new", + "init", + "resume", + "continue", + "undo", + "mcp", + "raw", + "exit", + ]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -59,6 +70,20 @@ test("findExactSlashCommand returns built-in /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"); @@ -66,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"); 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 256e0d6..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,6 +22,113 @@ 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"); @@ -88,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"); @@ -178,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"); @@ -395,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/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index eaa536e..417c6c4 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -20,34 +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(); @@ -128,7 +138,10 @@ test("WebSearch returns a configuration error when neither a script nor an LLM c 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."); + assert.equal( + result.error, + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json or ./.deepcode/settings.json." + ); }); function createContext( @@ -136,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; @@ -157,6 +171,7 @@ function createContext( model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, + env: options.env, machineId: options.machineId, }), onProcessStart: options.onProcessStart, diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index 1e5bc19..df7e109 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -1,17 +1,26 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +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", () => { diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 155d82a..4272271 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -1,5 +1,7 @@ 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, @@ -7,7 +9,7 @@ import { resolveShellPath, rewriteWindowsNullRedirect, toNativeCwd, -} from "./shell-utils"; +} from "../common/shell-utils"; const MAX_OUTPUT_CHARS = 30000; const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; @@ -22,6 +24,9 @@ type ToolCommandResult = { truncated: boolean; shellPath?: string; startCwd?: string; + timedOut?: boolean; + timeoutMs?: number; + deadlineAt?: string; }; export async function handleBashTool( @@ -48,12 +53,15 @@ export async function handleBashTool( execution.exitCode, execution.signal, shellPath, - startCwd + 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); + const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error, execution.timedOut); return formatResult({ ...result, ok: false }, "bash", errorMessage); } @@ -102,19 +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: buildShellEnv(shellPath), + env: buildShellEnv(shellPath, configuredEnv), detached, 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 = ""; @@ -123,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) => { @@ -133,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({ @@ -142,6 +220,9 @@ async function executeShellCommand( exitCode: typeof code === "number" ? code : null, signal: signal ?? null, error, + timedOut, + timeoutMs, + deadlineAtMs, }); }); }); @@ -168,7 +249,10 @@ function buildToolCommandResult( exitCode: number | null, signal: string | null, shellPath: string, - startCwd: string + startCwd: string, + timedOut: boolean = false, + timeoutMs?: number, + deadlineAtMs?: number ): ToolCommandResult { const { output: cleanedStdout, cwd } = stripMarker(stdout, marker); const combined = joinOutput(cleanedStdout, stderr); @@ -182,6 +266,9 @@ function buildToolCommandResult( truncated, shellPath, startCwd, + timedOut, + timeoutMs, + deadlineAt: typeof deadlineAtMs === "number" ? new Date(deadlineAtMs).toISOString() : undefined, }; } @@ -226,10 +313,13 @@ 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}.`; } @@ -248,6 +338,15 @@ function formatResult(result: ToolCommandResult, name: string, errorMessage?: st 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; diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 403f984..454a673 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -1,23 +1,32 @@ import * as fs from "fs"; 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"; +import { + buildDiffPreview, + hasFileChangedSinceState, + readTextFileWithMetadata, + writeTextFile, +} from "../common/file-utils"; +import { executeValidatedTool, semanticBoolean } from "../common/runtime"; import { createSnippet, getFileState, getSnippet, + hasSnippetOutdatedFileVersion, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState, -} from "./state"; +} 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[]; @@ -204,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) { @@ -237,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, @@ -288,15 +321,21 @@ export async function handleEditTool( 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, @@ -521,6 +560,10 @@ function applyReplacement( return result; } +function stripReadResultLineTabs(value: string): string { + return value.replaceAll("\n\t", "\n"); +} + function buildCandidateMetadata( sessionId: string, filePath: string, @@ -598,8 +641,8 @@ function findClosestMatch( } } - if (bestLooseMatch) { - return bestLooseMatch; + if (bestLooseMatch && bestLooseMatch.score >= MIN_FUZZY_SCORE) { + return expandClosestMatch(raw, lineIndex, scope, bestLooseMatch); } } @@ -635,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 { @@ -651,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; @@ -767,10 +826,6 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null 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 eec0b20..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; @@ -16,6 +18,7 @@ export type CreateOpenAIClient = () => { debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + env?: Record; machineId?: string; }; @@ -35,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; @@ -64,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; @@ -73,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(); } @@ -114,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); } @@ -159,8 +194,15 @@ 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, @@ -185,6 +227,10 @@ export class ToolExecutor { createOpenAIClient: this.createOpenAIClient, onProcessStart: hooks?.onProcessStart, 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); diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 548bcfd..964cdd7 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -2,8 +2,8 @@ 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, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "./state"; +import { readTextFileWithMetadata } from "../common/file-utils"; +import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "../common/state"; const DEFAULT_LINE_LIMIT = 2000; const MAX_LINE_LENGTH = 2000; 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 558271b..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; }; @@ -46,14 +47,15 @@ export async function handleWebSearchTool( 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)) { return { ok: false, name: "WebSearch", - error: "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json.", + error: + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json or ./.deepcode/settings.json.", }; } @@ -67,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; @@ -150,12 +153,13 @@ 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, + env: { ...process.env, ...configuredEnv }, stdio: ["ignore", "pipe", "pipe"], }); const pid = child.pid; diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 4524e21..a4c81bf 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -8,9 +8,9 @@ import { normalizeContent, readTextFileWithMetadata, writeTextFile, -} from "./file-utils"; -import { executeValidatedTool } from "./runtime"; -import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "./state"; +} 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."), @@ -97,16 +97,22 @@ export async function handleWriteTool( const encoding = existingMetadata?.encoding ?? "utf8"; 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, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ffe480d..ae94fa0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,81 +1,121 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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 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 = "", onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); - const { columns } = useWindowSize(); + 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 [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") { @@ -84,9 +124,53 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R } 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; @@ -115,12 +199,55 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R [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]); - const writeRef = useRef(write); + // 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) => { @@ -129,16 +256,13 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setTimeout(() => { const activeSessionId = sessionManager.getActiveSessionId(); const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const allMessages = activeSessionId - ? sessionManager.listSessionMessages(activeSessionId) - : messagesRef.current; - const resolved = resolveCurrentSettings(); - const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model }); + const summary = buildExitSummaryText({ session }); process.stdout.write("\n"); - process.stdout.write(chalk.green("> /exit ")); + 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; @@ -147,25 +271,34 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R if (onRestart) { onRestart(); } else { - writeRef.current("\u001B[2J\u001B[3J\u001B[H"); - sessionManager.setActiveSessionId(null); - setMessages([]); - setStatusLine(""); - setErrorLine(null); - setRunningProcesses(null); - setActiveStatus(null); - setDismissedQuestionIds(new Set()); - setShowWelcome(true); - setWelcomeNonce((n) => n + 1); - await refreshSkills(); + await resetToWelcome(); refreshSessionsList(); } return; } if (submission.command === "resume") { - setShowWelcome(false); 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; } @@ -174,7 +307,16 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R imageUrls: submission.imageUrls, 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) ?? []; @@ -183,15 +325,20 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || (submission.imageUrls.length > 0 ? "[Image]" : ""); - if (userDisplayContent) { + 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) { @@ -203,13 +350,79 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setRunningProcesses(null); } }, - [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] + [ + 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); @@ -217,38 +430,175 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R [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) => { - const currentSessionId = sessionManager.getActiveSessionId(); - if (currentSessionId !== sessionId) { - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - } sessionManager.setActiveSessionId(sessionId); - // 先清空让 的 index 重置为 0 - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - setView("chat"); - // 再加载新消息,此时 index 已为 0,会渲染全部 items - setTimeout(() => { - setMessages(loadVisibleMessages(sessionManager, sessionId)); - setShowWelcome(true); - }, 0); + // 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); + setActiveAskPermissions(session?.askPermissions); + if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { + setPendingPermissionReply(null); + } + await refreshSkills(sessionId); + }, + [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 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); }, - [sessionManager, refreshSkills] + [handleSelectSession, sessionManager, setMode] ); - const [stableColumns, setStableColumns] = useState(columns); useEffect(() => { - const timer = setTimeout(() => setStableColumns(columns), 100); - return () => clearTimeout(timer); - }, [columns]); - const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); + 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") @@ -263,7 +613,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation [busy, streamProgress, runningProcesses, nowTick] ); - const welcomeSettings = useMemo(() => resolveCurrentSettings(), []); + const welcomeItem: SessionMessage = useMemo( () => ({ id: `__welcome__${welcomeNonce}`, @@ -280,11 +630,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R [welcomeNonce] ); const staticItems = useMemo(() => { + if (mode === RawMode.Raw) { + return []; + } if (showWelcome && view === "chat") { return [welcomeItem, ...messages]; } return messages; - }, [showWelcome, view, messages, welcomeItem]); + }, [mode, showWelcome, view, messages, welcomeItem]); const handleQuestionAnswers = useCallback( (answers: AskUserQuestionAnswers) => { @@ -303,6 +656,46 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 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 ( @@ -312,14 +705,20 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R ); } - return ; + return ( + + ); }} {statusLine ? ( @@ -332,11 +731,41 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 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 : ( )} @@ -360,6 +806,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R ); } +export default App; + function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { if (message.role !== "assistant") { return false; @@ -392,6 +840,35 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session }; } +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}`); @@ -405,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; } @@ -417,70 +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"; - debugLogEnabled: boolean; - 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, - debugLogEnabled: settings.debugLogEnabled, - 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, - debugLogEnabled: settings.debugLogEnabled, - 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 952f9cf..7c76ae3 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/AskUserQuestionPrompt.tsx @@ -184,7 +184,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return ( - {isCursor ? "› " : " "} + {isCursor ? "> " : " "} {marker} {option.label} {option.isOther ? ( 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 ae9dd19..0000000 --- a/src/ui/MessageView.tsx +++ /dev/null @@ -1,337 +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, collapsed }: 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); - if (collapsed !== false) { - return ( - - - - ); - } - return ( - - - {content ? {renderMarkdown(content)} : null} - - ); - } - - return ( - - - - - - {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 6a4f3fd..8c808e9 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,12 +1,21 @@ -import React, { useEffect, useState } from "react"; +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, + PASTE_MARKER_REGEX, backspace, + cleanPasteContent, deleteForward, + deletePasteMarkerBackward, + deletePasteMarkerForward, deleteWordBefore, + deleteWordAfter, + expandPasteMarkers, + findPasteMarkerContaining, getCurrentSlashToken, + hasActivePasteMarkers, insertText, isEmpty, killLine, @@ -20,37 +29,73 @@ import { moveUp, } from "./promptBuffer"; import type { PromptBufferState } from "./promptBuffer"; +import { + 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 { SkillInfo } from "../session"; +import type { PermissionScope, SessionEntry, SkillInfo, UserToolPermission } from "../session"; // Re-exported from prompt modules for backward compatibility -export { useTerminalInput, parseTerminalInput } from "./prompt"; +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; -import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; -import SlashCommandMenu from "./SlashCommandMenu"; +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 SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -70,19 +115,26 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ + projectRoot, skills, + modelConfig, screenWidth, promptHistory, busy, loadingText, disabled, placeholder, + runningProcesses, + promptDraft, onSubmit, + onModelConfigChange, onInterrupt, + onToggleProcessStdout, + onRawModeChange, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); @@ -93,30 +145,94 @@ export const PromptInput = React.memo(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 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 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 = React.useMemo( - () => (showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []), - [showSkillsDropdown, slashToken, slashItems] + () => + showSkillsDropdown || showModelDropdown || showFileMentionMenu + ? [] + : slashToken + ? filterSlashCommands(slashItems, slashToken) + : [], + [showSkillsDropdown, showModelDropdown, showFileMentionMenu, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; 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"; + ? `${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); + useTerminalExtendedKeys(stdout, !disabled); + useBracketedPaste(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); + const refreshFileMentionItems = React.useCallback(() => { + setFileMentionItems(scanFileMentionItems(projectRoot)); + }, [projectRoot]); + + useEffect(() => { + refreshFileMentionItems(); + }, [refreshFileMentionItems]); + + useEffect(() => { + if (wasBusyRef.current && !busy) { + refreshFileMentionItems(); + } + wasBusyRef.current = busy; + }, [busy, refreshFileMentionItems]); + + useEffect(() => { + if (hasFileMentionToken && !hadFileMentionTokenRef.current) { + refreshFileMentionItems(); + } + hadFileMentionTokenRef.current = hasFileMentionToken; + }, [hasFileMentionToken, refreshFileMentionItems]); + useEffect(() => { if (!showMenu) { setMenuIndex(0); @@ -128,10 +244,10 @@ export const PromptInput = React.memo(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) { @@ -141,6 +257,23 @@ export const PromptInput = React.memo(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); @@ -162,8 +295,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { - if (showSkillsDropdown) { - setShowSkillsDropdown(false); + if (showFileMentionMenu) { return; } if (busy) { @@ -173,6 +305,15 @@ export const PromptInput = React.memo(function PromptInput({ return; } + if (key.ctrl && (input === "o" || input === "O")) { + if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) { + onToggleProcessStdout(); + } else { + expandPasteMarkerAtCursor(); + } + return; + } + if (key.ctrl && (input === "d" || input === "D")) { if (!isEmpty(buffer)) { updateBuffer((s) => deleteForward(s)); @@ -195,6 +336,9 @@ export const PromptInput = React.memo(function PromptInput({ setStatusMessage("Interrupting…"); } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -205,34 +349,17 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + return; + } + if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { exitHistoryBrowsing(); } - if (showSkillsDropdown) { - if (skills.length === 0) { - setShowSkillsDropdown(false); - } else { - 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) { - toggleSelectedSkill(skill); - } - return; - } - if (key.tab) { - setShowSkillsDropdown(false); - return; - } - } + if (key.paste) { + handlePaste(input); + return; } if (key.ctrl && (input === "v" || input === "V")) { @@ -263,7 +390,14 @@ export const PromptInput = React.memo(function PromptInput({ } const noModifier = !key.shift && !key.ctrl && !key.meta; - const isPlainReturn = key.return && !key.shift && !key.meta; + const returnAction = getPromptReturnKeyAction(key); + const isPlainReturn = returnAction === "submit"; + + if (showFileMentionMenu) { + if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { + return; + } + } if (showMenu) { if (key.upArrow) { @@ -274,7 +408,7 @@ export const PromptInput = React.memo(function PromptInput({ setMenuIndex((idx) => (idx + 1) % slashMenu.length); return; } - if (key.tab || (key.return && !key.shift && !key.meta)) { + if (key.tab || returnAction === "submit") { const selected = slashMenu[menuIndex]; if (selected) { handleSlashSelection(selected); @@ -288,23 +422,23 @@ export const PromptInput = React.memo(function PromptInput({ return; } - if (key.return) { - const isShiftEnter = key.shift || key.meta; - if (isShiftEnter) { - updateBuffer((s) => insertText(s, "\n")); - return; - } + if (returnAction === "newline") { + updateBuffer((s) => insertText(s, "\n")); + return; + } + + if (returnAction === "submit") { submitCurrentBuffer(); return; } if (key.delete) { - updateBuffer((s) => deleteForward(s)); + updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s)); return; } if (key.backspace) { - updateBuffer((s) => backspace(s)); + updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s)); return; } @@ -394,17 +528,34 @@ export const PromptInput = React.memo(function PromptInput({ } 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; @@ -420,6 +571,28 @@ export const PromptInput = React.memo(function PromptInput({ { isActive: !disabled } ); + function undo(): void { + const previous = undoPromptEdit(undoRedoRef.current, buffer); + if (!previous) { + return; + } + exitHistoryBrowsing(); + setBuffer(previous); + } + + function redo(): void { + const next = redoPromptEdit(undoRedoRef.current, buffer); + if (!next) { + return; + } + exitHistoryBrowsing(); + setBuffer(next); + } + + function clearUndoRedoStacks(): void { + clearPromptUndoRedoState(undoRedoRef.current); + } + function exitHistoryBrowsing(): void { setHistoryCursor(-1); setDraftBeforeHistory(null); @@ -427,7 +600,86 @@ export const PromptInput = React.memo(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 { @@ -456,6 +708,25 @@ export const PromptInput = React.memo(function PromptInput({ 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"); @@ -473,33 +744,51 @@ export const PromptInput = React.memo(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({ text: "/init", imageUrls: [] }); - setBuffer(EMPTY_BUFFER); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + 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: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); return; } } @@ -524,14 +813,11 @@ export const PromptInput = React.memo(function PromptInput({ } onSubmit({ - text: buffer.text, + text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, selectedSkills, }); - setBuffer(EMPTY_BUFFER); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); } function addSelectedSkill(skill: SkillInfo): void { @@ -545,10 +831,16 @@ export const PromptInput = React.memo(function PromptInput({ function clearSlashToken(): void { exitHistoryBrowsing(); setBuffer((state) => removeCurrentSlashToken(state)); + clearUndoRedoStacks(); } - const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); - const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); + const showFooterText = useMemo( + () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] + ); + + const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; + const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join(ARGS_SEPARATOR)}` : ""; return ( @@ -576,39 +868,45 @@ export const PromptInput = React.memo(function PromptInput({ borderDimColor > - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + {inlineHint ? {inlineHint} : 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 to close - - ) : null} + onRawModeChange?.(mode)} + screenWidth={screenWidth} + /> + + setShowModelDropdown(false)} + onModelConfigChange={onModelConfigChange} + onStatusMessage={setStatusMessage} + /> + { + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); + } + }} + onSelect={insertFileMentionSelection} + /> - {!showMenu && ( + {!showFooterText && ( {footerText} @@ -634,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; @@ -649,6 +943,14 @@ export function toggleSkillSelection(skills: SkillInfo[], skill: SkillInfo): Ski 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 { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { @@ -668,12 +970,27 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick): PromptReturnKeyAction { + if (!key.return) { + return null; + } + if (key.shift || key.meta) { + return "newline"; + } + return "submit"; +} + +export function renderBufferWithCursor( + state: PromptBufferState, + isFocused: boolean, + placeholder?: string, + validPastes?: Map +): 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); + const validIds = validPastes ?? new Map(); if (text.length === 0 && placeholder) { if (!isFocused) { @@ -682,16 +999,107 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } + if (text.length === 0) { + return isFocused ? renderCursorCell(" ") : ""; + } + if (!isFocused) { - return text.endsWith("\n") ? `${text} ` : text; + return highlightPasteMarkersInText(text, validIds); + } + + return renderFocusedText(text, cursor, validIds); +} + +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; + } + result += s.slice(pos); + return result.endsWith("\n") ? `${result} ` : result; +} + +/** + * 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; + } + + // 3. Remainder after the last marker. + result += renderTextSegmentWithCursor(text, pos, text.length, cursor, false); + + return result; +} + +/** + * 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; } - if (typeof at === "undefined") { - return before + renderCursorCell(" "); + // Cursor is exactly at `end` (which equals `segText.length`). + if (cursorRel === segText.length) { + return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" "); } + + // Cursor is somewhere inside the segment. + const at = segText[cursorRel]; + 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; } + + 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; } diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 67f2e10..2d83b84 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,32 +1,67 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { SessionEntry } from "../session"; +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, footer, scroll indicator, etc. - // Outer container height=rows-1, outer border 2 + header 1 + inner border 2 + footer 1 + scroll indicator 1 = 8 - const reservedLines = 8; + // 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]); - - // Ensure index stays within valid range - const safeIndex = useMemo(() => { - if (sessions.length === 0) return 0; - return Math.max(0, Math.min(index, sessions.length - 1)); - }, [index, sessions.length]); + }, [rows, searchQuery]); // Calculate scroll offset to keep the selected item visible const scrollOffset = useMemo(() => { @@ -36,23 +71,84 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac // Get the currently visible session list const visibleSessions = useMemo(() => { - return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); - }, [sessions, scrollOffset, maxVisibleSessions]); + 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; } - if (sessions.length === 0) { + + // 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) { @@ -60,7 +156,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } if (key.pageDown) { - setIndex((i) => Math.min(sessions.length - 1, i + maxVisibleSessions)); + setIndex((i) => Math.min(filteredSessions.length - 1, i + maxVisibleSessions)); return; } if (key.home) { @@ -68,17 +164,19 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } if (key.end) { - setIndex(sessions.length - 1); + setIndex(filteredSessions.length - 1); return; } if (key.return) { - const session = sessions[safeIndex]; + const session = filteredSessions[safeIndex]; if (session) { onSelect(session.id); } } }); + const hasActiveSearch = searchQuery.trim().length > 0; + if (sessions.length === 0) { return ( @@ -99,15 +197,24 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac > {/* Header row */} - - - Resume a session - - - {" "} - ({sessions.length} total) - + + + + Resume a session + + + {" "} + ({sessions.length} total + {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) + + + {/* Search bar */} + + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} + + {/* Session list */} - {visibleSessions.map((session, i) => { - const actualIndex = scrollOffset + i; - return ( - - - {actualIndex === safeIndex ? "› " : " "} - - - - - {formatSessionTitle(session.summary || "Untitled")} - - ({session.status}) + {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 ? "> " : " "} - - {formatTimestamp(session.updateTime)} + + + + {formatSessionTitle(session.summary || "Untitled")} + + {isConfirming ? ( + [Delete? Enter=yes, Esc=no] + ) : ( + ({formatSessionStatus(session.status)}) + )} + + + {formatTimestamp(session.updateTime)} + - - ); - })} - {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? ( + ); + }) + )} + {scrollOffset > 0 || scrollOffset + maxVisibleSessions < filteredSessions.length ? ( - {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null} - {scrollOffset + maxVisibleSessions < sessions.length ? ( - … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. + {scrollOffset > 0 ? … {scrollOffset} sessions above. : null} + {scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + … {filteredSessions.length - scrollOffset - maxVisibleSessions} sessions below. ) : null} ) : null} {/* Footer */} - - ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel + + {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 + + + )} @@ -179,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 index 9b79293..df599b5 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -1,7 +1,9 @@ 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[]; @@ -9,20 +11,24 @@ type SlashCommandMenuProps = { 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) + // 计算标签列最佳宽度:包含前缀"> "或" "(2字符),不超过容器一半(扣除gap) const labelColumnWidth = React.useMemo(() => { if (items.length === 0) { return 0; } - const longestLabel = Math.max(...items.map((s) => s.label.length)); - const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " + 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]); @@ -49,11 +55,12 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ const actualIndex = visibleStart + idx; return ( - + - {actualIndex === activeIndex ? "› " : " "} + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} + {item.args ? {item.args.join(ARGS_SEPARATOR)} : null} 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/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 5e25379..7e740d1 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -7,12 +7,12 @@ import type { ResolvedDeepcodingSettings } from "../settings"; 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; }; @@ -28,13 +28,8 @@ const SHORTCUT_TIPS = [ { 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; @@ -68,7 +63,7 @@ export function WelcomeScreen({ {!compact ? : null} - + 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 index 910cceb..c55d9ce 100644 --- a/src/ui/exitSummary.ts +++ b/src/ui/exitSummary.ts @@ -1,11 +1,9 @@ import chalk from "chalk"; import gradientString from "gradient-string"; -import type { SessionEntry, SessionMessage } from "../session"; +import type { ModelUsage, SessionEntry } from "../session"; type ExitSummaryInput = { session: SessionEntry | null; - messages: SessionMessage[]; - model?: string; }; const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; @@ -32,13 +30,15 @@ type UsageFields = { promptTokens: number; completionTokens: number; cachedTokens: number; + totalReqs: number; }; -function extractUsageFields(usage: unknown | null): UsageFields { +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; @@ -61,14 +61,13 @@ function extractUsageFields(usage: unknown | null): UsageFields { cachedTokens = record.prompt_cache_hit_tokens; } - return { promptTokens, completionTokens, cachedTokens }; + const totalReqs = typeof record.total_reqs === "number" ? record.total_reqs : 0; + + return { promptTokens, completionTokens, cachedTokens, totalReqs }; } export function buildExitSummaryText(input: ExitSummaryInput): string { - const { session, messages, model } = input; - - // Count assistant messages as the request count shown in the usage table. - const assistantCount = messages.filter((m) => m.role === "assistant").length; + const { session } = input; const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding @@ -81,9 +80,22 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const rows: string[] = ["", `${header}`, ""]; - const usage = extractUsageFields(session?.usage ?? null); - const modelName = model ?? "unknown"; - const hasUsage = usage.promptTokens > 0 || usage.completionTokens > 0; + 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; @@ -103,17 +115,19 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(chalk.bold(headerRow)); rows.push(divider); - const reqsStr = String(assistantCount).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); + 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(""); } 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 index a151e9c..1348903 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,24 +1,45 @@ -export { App, readSettings, resolveCurrentSettings, createOpenAIClient } from "./App"; +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, parseDiffPreview } from "./MessageView"; +export { MessageView } from "./components"; +export { parseDiffPreview } from "./components/MessageView/utils"; export { PromptInput, IMAGE_ATTACHMENT_CLEAR_HINT, formatImageAttachmentStatus, formatSelectedSkillsStatus, - isSkillSelected, addUniqueSkill, toggleSkillSelection, removeCurrentSlashToken, isClearImageAttachmentsShortcut, + getPromptReturnKeyAction, renderBufferWithCursor, + buildInitPromptSubmission, useTerminalInput, parseTerminalInput, + dispatchTerminalInput, type PromptSubmission, + type PromptDraft, type InputKey, } from "./PromptInput"; -export { getPromptCursorPlacement } from "./prompt/cursor"; -export { SessionList, formatSessionTitle } from "./SessionList"; +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"; @@ -33,7 +54,7 @@ export { } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./markdown"; +export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; export { EMPTY_BUFFER, insertText, @@ -49,6 +70,7 @@ export { moveLineEnd, killLine, deleteWordBefore, + deleteWordAfter, reset, isEmpty, getCurrentSlashToken, @@ -64,5 +86,14 @@ export { 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 11fb0ea..0000000 --- a/src/ui/markdown.ts +++ /dev/null @@ -1,117 +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 index 8ccdc60..aefea34 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,6 +40,22 @@ 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, @@ -239,3 +255,29 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined }; }, [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 index 857b8ac..6435f62 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,8 +1,10 @@ -export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; export { useHiddenTerminalCursor, + useTerminalExtendedKeys, + useBracketedPaste, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index a0f454f..e3d6349 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -20,13 +20,15 @@ export type InputKey = { 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"]); +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"]); @@ -35,9 +37,84 @@ 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", @@ -57,6 +134,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: 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) { @@ -103,6 +181,60 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: 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 } = {} @@ -112,8 +244,15 @@ export function useTerminalInput( 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); @@ -126,9 +265,76 @@ export function useTerminalInput( if (!isActive) { return; } + const handleData = (data: Buffer | string) => { - const { input, key } = parseTerminalInput(data); - handlerRef.current(input, key); + 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); diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index fb65179..3e0a710b 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -127,6 +127,24 @@ export function deleteWordBefore(state: PromptBufferState): PromptBufferState { }; } +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, + }; +} + export function reset(): PromptBufferState { return { ...EMPTY_BUFFER }; } @@ -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; 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 274940f..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" | "init" | "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[] = [ @@ -17,6 +29,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/skills", description: "List available skills", }, + { + kind: "model", + name: "model", + label: "/model", + description: "Select model, thinking mode and effort control", + }, { kind: "new", name: "new", @@ -35,6 +53,31 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/resume", 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", 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 626e529..fcd9bfb 100644 --- a/src/updateCheck.ts +++ b/src/updateCheck.ts @@ -1,4 +1,4 @@ -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"; @@ -6,6 +6,7 @@ import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; import { UpdatePrompt, type UpdatePromptChoice } from "./ui"; +import { killProcessTree } from "./common/process-tree"; export type PackageInfo = { name: string; @@ -161,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`); @@ -205,9 +205,8 @@ function runNpmViewLatestVersion( if (registry) { args.push("--registry", registry); } - const child = spawn("npm", args, { + const child = spawnNpm(args, { stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", }); let stdout = ""; @@ -222,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); @@ -241,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) { diff --git a/docs/prompts/init_command.md.ejs b/templates/prompts/init_command.md.ejs similarity index 100% rename from docs/prompts/init_command.md.ejs rename to templates/prompts/init_command.md.ejs 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 77% rename from docs/tools/bash.md rename to templates/tools/bash.md index 5f57d1e..83027d3 100644 --- a/docs/tools/bash.md +++ b/templates/tools/bash.md @@ -7,7 +7,6 @@ On Windows, Bash runs through Git Bash. Use POSIX commands and quote Windows pat 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. @@ -29,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: @@ -61,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