diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 9cdf64e..7f9cf35 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -4,40 +4,47 @@ ``` src/ -├── cli.tsx # Entry point — parses args, renders Ink App +├── 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, agent-drift-guard skill +├── 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 +│ ├── 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 slash commands, image paste, skills +│ ├── PromptInput.tsx # Multi-line input with file mentions (@), slash commands, image paste, skills │ ├── MessageView.tsx # Renders assistant/tool messages with markdown -│ ├── DropdownMenu.tsx # Reusable dropdown for skill/model selection -│ ├── SessionList.tsx # Session picker for /resume -│ ├── promptUndoRedo.ts # Ctrl+- undo / Ctrl+Shift+- redo for prompt input +│ ├── 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 +│ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution, status ├── tools/ -│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers -│ ├── bash-handler.ts # Executes shell commands -│ ├── read-handler.ts # Reads files and images +│ ├── 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 in files -│ ├── web-search-handler.ts # Web search tool -│ └── ask-user-question-handler.ts # Interactive user prompts -├── tests/ # Test suite — one *.test.ts per module +│ ├── 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 +docs/ # User-facing documentation (configuration, MCP, skills) dist/ # Bundled CLI output (gitignored) ``` @@ -80,7 +87,7 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl - **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. +- 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 @@ -102,12 +109,18 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl ## Architecture Overview -The 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). +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). -Six tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `templates/tools/`. +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. -- The built-in `agent-drift-guard` skill is always injected into every session. +- **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/.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/README_en.md b/README-en.md similarity index 60% rename from README_en.md rename to README-en.md index ee5a103..c1d4acb 100644 --- a/README_en.md +++ b/README-en.md @@ -1,7 +1,24 @@ -# Deep Code CLI +
+
+
+

+ + 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 @@ -47,16 +64,19 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap ## Slash Commands & Keyboard Shortcuts -| Slash Command | Action | -|------------------|----------------------------------------------------------| -| `/` | Open the skills / commands menu | -| `/new` | Start a fresh conversation | -| `/resume` | Choose a previous conversation to continue | -| `/model` | Switch model, thinking mode, and reasoning effort | -| `/init` | Initialize an AGENTS.md file (LLM project instructions) | -| `/skills` | List available skills | -| `/mcp` | View MCP server status and available tools | -| `/exit` | Quit (also `Ctrl+D` twice) | +| 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 | |------------------|----------------------------------------------------------| @@ -84,7 +104,7 @@ Deep Code supports multimodal input — you can paste images from the clipboard ### 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 +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? @@ -111,6 +131,16 @@ Deep Code supports MCP (Model Context Protocol) to connect external services suc 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: @@ -151,3 +181,23 @@ 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 69d28c8..2643756 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,20 @@ -# Deep Code CLI +
+
+
+

+ + 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 集成。 @@ -47,24 +63,27 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: ## 斜杠命令与按键功能 -| 斜杠命令 | 操作 | -|-----------------|---------------------------------------------| -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/model` | 切换模型、思考模式和推理强度 | -| `/init` | 初始化 AGENTS.md 文件 | -| `/skills` | 列出可用 skills | -| `/mcp` | 查看 MCP 服务器状态和可用工具 | -| `/exit` | 退出(也可用连续 `Ctrl+D`) | - -| 按键 | 操作 | -|-----------------|---------------------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| 连续 `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` | 退出 | ## 支持的模型 @@ -85,7 +104,7 @@ 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)。 ### 怎样启用联网搜索功能? @@ -97,6 +116,15 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[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? @@ -112,6 +140,7 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 "thinkingEnabled": true } ``` + ## 贡献 欢迎贡献代码!以下是参与方式: @@ -152,3 +181,22 @@ npm link - 在 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 69d28c8..0000000 --- a/README_cn.md +++ /dev/null @@ -1,154 +0,0 @@ -# Deep Code CLI - -[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` | 选择历史对话继续 | -| `/model` | 切换模型、思考模式和推理强度 | -| `/init` | 初始化 AGENTS.md 文件 | -| `/skills` | 列出可用 skills | -| `/mcp` | 查看 MCP 服务器状态和可用工具 | -| `/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` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g - -### 怎样启用联网搜索功能? - -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) - - -### 是否支持 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) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 diff --git a/docs/configuration.md b/docs/configuration.md index f8e52c3..1cce9a1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,12 +67,24 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 +通知脚本执行时,会通过环境变量注入以下上下文信息: + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + ```json { - "notify": "/path/to/slack-notify.sh" + "notify": "/path/to/notify-script.sh" } ``` +> 详细的 Slack、飞书、终端通知、系统通知等配置示例,请参阅 [notify.md](notify.md)。 + #### `webSearchTool` — 自定义联网搜索 Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 369f8e4..fa396f9 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -67,12 +67,24 @@ When thinking mode is enabled, controls the depth of the model’s reasoning: 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/slack-notify.sh" + "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: 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 51b7a95..dfa3fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.21", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.21", + "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": { @@ -4096,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 90c2b51..71c171c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.21", + "version": "0.1.25", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", @@ -17,6 +17,7 @@ "dist/cli.js", "templates/tools/**", "templates/prompts/**", + "templates/skills/**", "README.md", "LICENSE" ], @@ -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": { diff --git a/src/cli.tsx b/src/cli.tsx index 112c9dd..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 "./common/shell-utils"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; +import { AppContainer } from "./ui"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -18,9 +18,11 @@ 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 User-level API key, model, base URL", @@ -39,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" @@ -49,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(); @@ -66,8 +83,15 @@ async function main(): Promise { function startApp(): void { let restarting = false; + const appInitialPrompt = initialPrompt; + initialPrompt = undefined; const inkInstance = render( - restartRef.current?.()} />, + restartRef.current?.()} + />, { exitOnCtrlC: false } ); diff --git a/src/common/bash-timeout.ts b/src/common/bash-timeout.ts new file mode 100644 index 0000000..0a76d21 --- /dev/null +++ b/src/common/bash-timeout.ts @@ -0,0 +1,12 @@ +export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000; +export const MIN_BASH_TIMEOUT_MS = 60 * 1000; +export const BASH_TIMEOUT_INCREMENT_MS = 5 * 60 * 1000; +export const BASH_TIMEOUT_DECREMENT_MS = 60 * 1000; + +export function clampBashTimeoutMs(timeoutMs: number, minTimeoutMs: number = MIN_BASH_TIMEOUT_MS): number { + if (!Number.isFinite(timeoutMs)) { + return DEFAULT_BASH_TIMEOUT_MS; + } + const minimum = Number.isFinite(minTimeoutMs) ? Math.max(1, Math.round(minTimeoutMs)) : MIN_BASH_TIMEOUT_MS; + return Math.max(minimum, Math.round(timeoutMs)); +} diff --git a/src/common/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/common/notify.ts b/src/common/notify.ts index 8878c50..d1b541b 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -16,11 +16,40 @@ 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( @@ -28,7 +57,8 @@ export function launchNotifyScript( durationMs: number, workingDirectory?: string, spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, - configuredEnv: Record = {} + configuredEnv: Record = {}, + context: NotifyContext = {} ): void { const commandPath = notifyPath?.trim(); if (!commandPath) { @@ -38,7 +68,7 @@ export function launchNotifyScript( const options = { cwd: workingDirectory, detached: process.platform !== "win32", - env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }), + 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/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/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index 9636732..26a7a32 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "child_process"; import { createInterface, type Interface } from "readline"; -import * as os from "os"; import * as path from "path"; +import { killProcessTree } from "../common/process-tree"; type JsonRpcRequest = { jsonrpc: "2.0"; @@ -96,6 +96,13 @@ type ReadResourceResult = { 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; @@ -106,55 +113,67 @@ export class McpClient { >(); 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 + 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); - const isWindows = os.platform() === "win32"; - - if (isWindows) { - // On Windows, shell: true lets cmd.exe resolve the command via - // PATHEXT (npx → npx.cmd, etc.) without blindly appending .cmd, - // which would break absolute paths like process.execPath. - this.process = spawn(this.command, args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - shell: true, - windowsHide: true, - }); - } else { - this.process = spawn(this.command, args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - }); - } + 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) => { - reject(this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)); + safeReject( + this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`) + ); }); this.process.on("close", (code) => { - const error = this.withStderr(`MCP server "${this.serverName}" exited with code ${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) { @@ -263,16 +282,25 @@ export class McpClient { } disconnect(): void { + this.intentionallyDisconnected = true; if (this.reader) { this.reader.close(); this.reader = null; } if (this.process) { - this.process.kill(); + 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++; @@ -388,3 +416,31 @@ export class McpClient { 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 index 5a9f553..fe8066b 100644 --- a/src/mcp/mcp-manager.ts +++ b/src/mcp/mcp-manager.ts @@ -1,7 +1,9 @@ import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client"; import type { McpServerConfig } from "../settings"; -const MCP_STARTUP_TIMEOUT_MS = 30_000; +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 = { @@ -14,7 +16,7 @@ type McpToolEntry = { export type McpServerStatus = { name: string; - status: "starting" | "ready" | "failed"; + status: "starting" | "ready" | "failed" | "reconnecting"; connected: boolean; error?: string; toolCount: number; @@ -46,12 +48,10 @@ export class McpManager { 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; - // Clear the disposed flag — a re-prepare means we are live again. - // (disconnect() sets disposed=true to stop a stale initialize() loop, - // but prepare+initialize must be able to start a new one.) this.disposed = false; for (const name of Object.keys(servers)) { @@ -81,116 +81,176 @@ export class McpManager { if (!servers || Object.keys(servers).length === 0) return; - const entries = Object.entries(servers); + this.serverConfigs = servers; this.prepare(servers); - for (const [name, config] of entries) { + for (const [name, config] of Object.entries(servers)) { if (this.disposed) break; - let client: McpClient | null = null; - try { - client = new McpClient(name, config.command, config.args ?? [], config.env, (method) => { + 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(() => { - // swallow refresh errors - }); + 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(); - break; - } - this.clients.push(client); - - // Discover tools - const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); - if (this.disposed) break; - 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); - } - - // Discover prompts - let serverPrompts: McpPromptDefinition[] = []; - try { - serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); - } catch { - // Server may not support prompts — safe to ignore - } - if (this.disposed) break; - 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); } + ); + await client.connect(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) { + client.disconnect(); + return; + } + this.clients.push(client); - // Discover resources - let serverResources: McpResourceDefinition[] = []; - try { - serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); - } catch { - // Server may not support resources — safe to ignore - } - if (this.disposed) break; - 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); - } + 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); + } - this.setStatus({ - name, - status: "ready", - connected: true, - toolCount: serverTools.length, - tools: toolNamespacedNames, - promptCount: serverPrompts.length, - prompts: promptNamespacedNames, - resourceCount: serverResources.length, - resources: resourceNamespacedNames, + 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, }); - } catch (err) { - if (this.disposed) break; - client?.disconnect(); - const message = err instanceof Error ? err.message : String(err); - // 不在控制台输出错误日志,避免暴露敏感信息 - // process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); - this.setStatus({ - name, - status: "failed", - connected: false, - error: message, - toolCount: 0, - tools: [], - promptCount: 0, - prompts: [], - resourceCount: 0, - resources: [], + 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)); @@ -345,12 +405,12 @@ export class McpManager { 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); - // Remove old tool entries for this server this.tools = this.tools.filter((t) => t.serverName !== serverName); const toolNamespacedNames: string[] = []; for (const tool of serverTools) { @@ -364,13 +424,11 @@ export class McpManager { }); toolNamespacedNames.push(namespacedName); } - // Update status const existing = this.serverStatuses.find((s) => s.name === serverName); if (existing) { existing.toolCount = serverTools.length; existing.tools = toolNamespacedNames; } - // Notify listener this.onToolsListChanged?.(); } @@ -390,7 +448,6 @@ export class McpManager { } else { this.serverStatuses[index] = status; } - // 触发状态变更回调 this.onStatusChanged?.(); } } diff --git a/src/prompt.ts b/src/prompt.ts index 4774725..ba9bf23 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -8,161 +8,6 @@ import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; -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. -`; - 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. @@ -254,6 +99,8 @@ type PromptToolOptions = { webSearchEnabled?: boolean; }; +const DEFAULT_SKILL_TEMPLATES = ["agent-drift-guard.md", "plan-and-execute.md"]; + function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { const toolsDir = path.join(extensionRoot, "templates", "tools"); if (!fs.existsSync(toolsDir)) { @@ -281,6 +128,35 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s return docs.join("\n\n"); } +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()}日。随着对话的进行,时间在流逝。`; @@ -288,10 +164,10 @@ function getCurrentDateAndModelPrompt(model?: string): string { return prompt; } -export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}): string { +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${getCurrentDateAndModelPrompt(options.model)}\n\n${getRuntimeContext(projectRoot)}`; + return basePrompt; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -310,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" } : {}; @@ -328,7 +204,11 @@ function getRuntimeContext(projectRoot: string): string { jq: checkToolInstalled("jq"), }, }; - return `# Local Workspace Environment\n\n\`\`\`json + return `${getCurrentDateAndModelPrompt(model)} + +# Local Workspace Environment + +\`\`\`json ${JSON.stringify(env, null, 2)} \`\`\``; } @@ -451,8 +331,29 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe 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, }, }, @@ -509,6 +410,30 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe }, }, }, + { + 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: { diff --git a/src/session.ts b/src/session.ts index 8c078f3..a9fc39e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,15 +9,55 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open import { launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; -import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt"; -import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +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 } from "./settings"; +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; @@ -112,7 +152,15 @@ function getTotalTokens(usage: ModelUsage | 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; @@ -125,6 +173,21 @@ export type ModelUsage = { 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; summary: string | null; @@ -139,7 +202,8 @@ export type SessionEntry = { activeTokens: number; createTime: string; updateTime: string; - processes: Map | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: process info} + askPermissions?: AskPermissionRequest[]; }; export type SessionsIndex = { @@ -158,6 +222,8 @@ export type MessageMeta = { isSummary?: boolean; isModelChange?: boolean; skill?: SkillInfo; + permissions?: MessageToolPermission[]; + userPrompt?: UserPromptContent; }; export type SessionMessage = { @@ -173,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 = { @@ -191,12 +266,18 @@ export type SkillInfo = { type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; - getResolvedSettings: () => { model: string; webSearchTool?: string; mcpServers?: Record }; + 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 = { @@ -215,14 +296,17 @@ export class SessionManager { 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[] = []; @@ -235,6 +319,7 @@ export class SessionManager { this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; this.onMcpStatusChanged = options.onMcpStatusChanged; + this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); } @@ -255,6 +340,11 @@ export class SessionManager { 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(); } @@ -524,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; @@ -854,21 +945,8 @@ The candidate skills are as follows:\n\n`; const signal = controller?.signal; this.throwIfAborted(signal); - if (userPrompt.text) { - const skills = await this.listSkills(); - const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); - this.throwIfAborted(signal); - const skillSet = new Set(skillNames); - const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); - if (Array.isArray(userPrompt.skills)) { - userPrompt.skills.push(...matchedSkill); - } else if (matchedSkill.length > 0) { - userPrompt.skills = matchedSkill; - } - } - userPrompt.skills = await this.normalizeSkills(userPrompt.skills); - this.throwIfAborted(signal); const sessionId = crypto.randomUUID(); + this.ensureFileHistorySession(sessionId); const now = new Date().toISOString(); const index = this.loadSessionsIndex(); const entry: SessionEntry = { @@ -903,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) { @@ -944,11 +1046,15 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows, { + inheritedPermissions: this.getResolvedSettings().permissions, + }); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, + askPermissions: undefined, updateTime: now, })); @@ -957,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 }); @@ -974,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) { @@ -996,7 +1115,20 @@ ${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, env } = this.createOpenAIClient(); @@ -1055,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( @@ -1091,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; @@ -1101,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; } @@ -1114,7 +1302,6 @@ ${skillMd} return; } - const responseUsage = response.usage ?? null; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -1126,6 +1313,7 @@ ${skillMd} activeTokens: getTotalTokens(responseUsage), status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, + askPermissions: undefined, updateTime: new Date().toISOString(), })); @@ -1273,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: { @@ -1280,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 { @@ -1313,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); @@ -1356,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; @@ -1366,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)) { @@ -1386,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; @@ -1424,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 }); @@ -1534,6 +1901,8 @@ ${skillMd} visible: true, createTime: now, updateTime: now, + meta: { userPrompt: this.cloneUserPromptForMeta(prompt) }, + checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } @@ -1666,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, @@ -1695,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 }; } @@ -1731,6 +2154,72 @@ ${skillMd} return { waitingForUser }; } + private cloneUserPromptForMeta(prompt: UserPromptContent): UserPromptContent { + return { + text: prompt.text, + imageUrls: prompt.imageUrls ? [...prompt.imageUrls] : undefined, + skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined, + permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined, + alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined, + }; + } + + private hasTrailingPendingToolCalls(sessionId: string): boolean { + return this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)).toolCalls.length > 0; + } + + private async appendDeferredPermissionPrompt( + sessionId: string, + userPrompt: UserPromptContent | undefined, + controller: AbortController + ): Promise { + if (!userPrompt || this.isContinuePrompt(userPrompt)) { + return undefined; + } + const text = userPrompt.text ?? ""; + const hasUserContent = + text.trim().length > 0 || + (Array.isArray(userPrompt.imageUrls) && userPrompt.imageUrls.length > 0) || + (Array.isArray(userPrompt.skills) && userPrompt.skills.length > 0); + if (!hasUserContent) { + return undefined; + } + this.reportNewPrompt(); + const signal = controller.signal; + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { + const skills = await this.listSkills(sessionId); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; + } + } + userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); + this.throwIfAborted(signal); + if (userPrompt.skills && userPrompt.skills.length > 0) { + for (const skill of userPrompt.skills) { + if (skill.isLoaded) { + continue; + } + const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8"); + const skillPrompt = `Use the skill document below to assist the user:\n +<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}"> +${skillMd} +`; + const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill); + this.appendSessionMessage(sessionId, skillMessage); + this.onAssistantMessage(skillMessage, true); + } + } + return undefined; + } + private buildOpenAIMessages( messages: SessionMessage[], thinkingEnabled: boolean, @@ -1859,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, @@ -1987,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]; @@ -2059,7 +2571,23 @@ ${skillMd} return; } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv); + // 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 { @@ -2077,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)); @@ -2088,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 []; } @@ -2121,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 { @@ -2150,6 +2718,7 @@ ${skillMd} 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), }; } @@ -2160,7 +2729,9 @@ ${skillMd} status === "processing" || status === "waiting_for_user" || status === "completed" || - status === "interrupted" + status === "interrupted" || + status === "ask_permission" || + status === "permission_denied" ) { return status; } @@ -2184,11 +2755,11 @@ ${skillMd} return usagePerModel; } - private deserializeProcesses(value: unknown): Map | null { + 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; @@ -2197,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 b5bb869..e0b1776 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -17,6 +17,27 @@ export type McpServerConfig = { 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; @@ -26,6 +47,7 @@ export type DeepcodingSettings = { notify?: string; webSearchTool?: string; mcpServers?: Record; + permissions?: PermissionSettings; }; export type ResolvedDeepcodingSettings = { @@ -39,6 +61,7 @@ export type ResolvedDeepcodingSettings = { notify?: string; webSearchTool?: string; mcpServers?: Record; + permissions: Required; }; export type ModelConfigSelection = { @@ -75,6 +98,79 @@ function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +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) { @@ -233,6 +329,7 @@ export function resolveSettingsSources( notify: notify || undefined, webSearchTool: webSearchTool || undefined, mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), + permissions: mergePermissions(userSettings, projectSettings), }; } 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/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 28c6488..953de7c 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; -import { getSystemPrompt, getTools } from "../prompt"; +import { getDefaultSkillPrompt, getRuntimeContext, getSystemPrompt, getTools } from "../prompt"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -12,16 +12,70 @@ test("getTools always includes WebSearch", () => { 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 current date guidance", () => { +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), true); + 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", () => { @@ -34,6 +88,8 @@ 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/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index 69d2075..4f8b4d9 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -19,10 +19,23 @@ import { 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"); @@ -71,6 +84,45 @@ test("parseTerminalInput keeps DEL payload for meta+backspace", () => { 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"); @@ -112,6 +164,31 @@ test("terminal extended key helpers request and restore modifyOtherKeys mode", ( 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"); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 3dad6df..95de8e3 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1,11 +1,14 @@ 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[] = []; @@ -20,6 +23,7 @@ function setHomeDir(dir: string): void { afterEach(() => { globalThis.fetch = originalFetch; + console.warn = originalConsoleWarn; if (originalHome === undefined) { delete process.env.HOME; } else { @@ -479,6 +483,60 @@ rl.on("line", (line) => { 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({ @@ -607,6 +665,40 @@ test("createSession stores /init and sends the active .deepcode project AGENTS p assert.ok(!systemContents.includes("root project instructions")); }); +test("createSession appends default system prompts in prefix-cache-friendly order", async () => { + const workspace = createTempDir("deepcode-system-order-workspace-"); + const home = createTempDir("deepcode-system-order-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-system-order"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "hello" }); + const systemContents = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(systemContents.length >= 4, true); + assert.match(systemContents[0] ?? "", /# Available Tools/); + assert.doesNotMatch(systemContents[0] ?? "", /# Local Workspace Environment/); + assert.doesNotMatch(systemContents[0] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[1] ?? "", //); + assert.match(systemContents[1] ?? "", //); + assert.doesNotMatch(systemContents[1] ?? "", /path="templates\/skills\//); + assert.doesNotMatch(systemContents[1] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[2] ?? "", /# Local Workspace Environment/); + assert.match(systemContents[2] ?? "", /当前LLM模型为test-model/); + const environmentJsonMatch = (systemContents[2] ?? "").match(/```json\n([\s\S]+?)\n```/); + assert.ok(environmentJsonMatch); + const environmentInfo = JSON.parse(environmentJsonMatch[1] ?? "{}") as { "root path"?: string }; + assert.equal(environmentInfo["root path"], workspace); + assert.equal(systemContents[3], "root project instructions"); +}); + test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { const workspace = createTempDir("deepcode-init-root-workspace-"); const home = createTempDir("deepcode-init-root-home-"); @@ -680,47 +772,695 @@ test("createSession reports a new prompt with the machineId token", async () => activatedSessionIds.push(sessionId); }; - const sessionId = await manager.createSession({ text: "hello world" }); - await flushPromises(); + const sessionId = await manager.createSession({ text: "hello world" }); + await flushPromises(); + + assert.equal(activatedSessionIds.length, 1); + assert.equal(activatedSessionIds[0], sessionId); + 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"); +}); + +test("replySession reports a new prompt with the machineId token", async () => { + const workspace = createTempDir("deepcode-reply-workspace-"); + const home = createTempDir("deepcode-reply-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-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.equal(activatedSessionIds.length, 1); - assert.equal(activatedSessionIds[0], sessionId); - 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-123"); + assert.match(toolMessage?.content ?? "", /allowed content/); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd"]); + assert.equal(manager.getSession(sessionId)?.status, "completed"); }); -test("replySession reports a new prompt with the machineId token", async () => { - const workspace = createTempDir("deepcode-reply-workspace-"); - const home = createTempDir("deepcode-reply-home-"); +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 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-456"); + 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 () => { @@ -1040,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( @@ -1367,7 +2208,7 @@ 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-"); setHomeDir(home); @@ -1396,7 +2237,13 @@ 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 () => { @@ -1446,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, @@ -1462,6 +2581,49 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa }); } +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: () => {}, + }); +} + function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager { const client = { chat: { @@ -1489,6 +2651,42 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow }); } +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: () => {}, + }); +} + function createMockedClientSessionManagerWithClient(projectRoot: string, client: unknown): SessionManager { return new SessionManager({ projectRoot, @@ -1545,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 6990288..52f8671 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,6 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify"; +import { + buildNotifyEnv, + formatDurationSeconds, + launchNotifyScript, + type NotifyContext, + type NotifySpawn, +} from "../common/notify"; import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; const TEST_PROCESS_ENV = {}; @@ -141,6 +147,35 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre 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( { @@ -358,14 +393,79 @@ test("formatDurationSeconds preserves sub-second precision and trims trailing ze assert.equal(formatDurationSeconds(4000), "4"); }); -test("buildNotifyEnv injects DURATION", () => { +test("buildNotifyEnv injects DURATION without context", () => { const env = buildNotifyEnv(2750, { HOME: "/tmp/home" }); assert.equal(env.HOME, "/tmp/home"); assert.equal(env.DURATION, "2"); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => { + const context: NotifyContext = { + status: "failed", + failReason: "API key not found", + body: "Hello, this is the last assistant message.", + title: "Fix login bug", + }; + const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context); + assert.equal(env.HOME, "/tmp/home"); + assert.equal(env.DURATION, "5"); + assert.equal(env.STATUS, "failed"); + assert.equal(env.FAIL_REASON, "API key not found"); + assert.equal(env.BODY, "Hello, this is the last assistant message."); + assert.equal(env.TITLE, "Fix login bug"); +}); + +test("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); +}); + +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)"); }); test( - "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", + "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts", { skip: process.platform === "win32" }, () => { const calls: Array<{ @@ -390,7 +490,13 @@ test( }; }; - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); + 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"); @@ -398,9 +504,16 @@ test( 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/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 45b6fdf..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", "model", "new", "init", "resume", "mcp", "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"); @@ -73,6 +98,13 @@ test("findExactSlashCommand returns built-in /model", () => { 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 43af7ca..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"); @@ -356,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"); @@ -573,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/tools/bash-handler.ts b/src/tools/bash-handler.ts index 95e7e76..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, @@ -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,10 +110,27 @@ 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, configuredEnv), @@ -114,8 +139,53 @@ async function executeShellCommand( 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 = ""; @@ -124,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) => { @@ -134,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({ @@ -143,6 +220,9 @@ async function executeShellCommand( exitCode: typeof code === "number" ? code : null, signal: signal ?? null, error, + timedOut, + timeoutMs, + deadlineAtMs, }); }); }); @@ -169,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); @@ -183,6 +266,9 @@ function buildToolCommandResult( truncated, shellPath, startCwd, + timedOut, + timeoutMs, + deadlineAt: typeof deadlineAtMs === "number" ? new Date(deadlineAtMs).toISOString() : undefined, }; } @@ -227,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}.`; } @@ -249,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 5aaede8..454a673 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -213,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) { @@ -308,7 +321,9 @@ 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, @@ -545,6 +560,10 @@ function applyReplacement( return result; } +function stripReadResultLineTabs(value: string): string { + return value.replaceAll("\n\t", "\n"); +} + function buildCandidateMetadata( sessionId: string, filePath: string, @@ -691,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; @@ -807,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 bc2d7d8..220fc89 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -4,6 +4,7 @@ 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"; @@ -37,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; @@ -66,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; @@ -118,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); } @@ -163,7 +194,8 @@ 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)) { @@ -195,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/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/write-handler.ts b/src/tools/write-handler.ts index 153c1c6..a4c81bf 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -97,7 +97,9 @@ 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( diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8c5c375..ae94fa0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -4,64 +4,88 @@ 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 { applyModelConfigSelection, - resolveSettingsSources, type DeepcodingSettings, type ModelConfigSelection, type ResolvedDeepcodingSettings, + resolveSettingsSources, } from "../settings"; -import { PromptInput, type PromptSubmission } from "./PromptInput"; -import { MessageView } from "./MessageView"; +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" | "mcp-status"; +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); @@ -69,8 +93,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 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(() => { @@ -81,11 +106,16 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 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") { @@ -98,9 +128,49 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R // 当 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; @@ -129,23 +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]); + // 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]); - const writeRef = useRef(write); writeRef.current = write; const handlePrompt = useCallback( async (submission: PromptSubmission) => { @@ -169,31 +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") { - setShowWelcome(false); setMcpStatuses(sessionManager.getMcpStatus()); - setView("mcp-status"); + navigateToSubView("mcp-status"); return; } @@ -202,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) ?? []; @@ -211,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) { @@ -231,13 +350,35 @@ 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); @@ -289,56 +430,160 @@ 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); // Clear first so resets its index to 0. - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - setView("chat"); - // Load messages after the reset so all static items are rendered. - setTimeout(() => { - setMessages(loadVisibleMessages(sessionManager, sessionId)); - setShowWelcome(true); - }, 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, refreshSkills] + [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); + }, + [handleSelectSession, sessionManager, setMode] ); - const [stableColumns, setStableColumns] = useState(columns); - useEffect(() => { - const timer = setTimeout(() => setStableColumns(columns), 100); - return () => clearTimeout(timer); - }, [columns]); - const lastRenderedColumnsRef = useRef(null); useEffect(() => { if (!stdout?.isTTY) { return; } - if (stableColumns <= 0) { + if (columns <= 0) { return; } if (lastRenderedColumnsRef.current === null) { - lastRenderedColumnsRef.current = stableColumns; + lastRenderedColumnsRef.current = columns; + return; + } + if (lastRenderedColumnsRef.current === columns) { return; } - if (lastRenderedColumnsRef.current === stableColumns) { + 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; } - lastRenderedColumnsRef.current = stableColumns; // Force full redraw on terminal resize to avoid stale wrapped rows. writeRef.current("\u001B[2J\u001B[H"); + setMessages([]); setShowWelcome(false); setWelcomeNonce((n) => n + 1); @@ -350,8 +595,10 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setMessages(nextMessages); setShowWelcome(true); }, 0); - }, [busy, sessionManager, stableColumns, stdout]); - const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); + }, [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") @@ -366,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 = resolvedSettings; + const welcomeItem: SessionMessage = useMemo( () => ({ id: `__welcome__${welcomeNonce}`, @@ -383,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) => { @@ -406,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 ( @@ -415,9 +705,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R ); @@ -442,31 +731,74 @@ 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")} /> + 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 : ( )} @@ -474,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; @@ -506,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}`); @@ -584,69 +947,7 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res ); } -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 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, - 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; - } -} +export { createOpenAIClient } from "../common/openai-client"; function getUserSettingsPath(): string { return path.join(os.homedir(), ".deepcode", "settings.json"); 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/McpStatusList.tsx b/src/ui/McpStatusList.tsx index a09039d..095612a 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/McpStatusList.tsx @@ -5,9 +5,10 @@ import type { McpServerStatus } from "../mcp/mcp-manager"; type Props = { statuses: McpServerStatus[]; onCancel: () => void; + onReconnect: (name: string) => void; }; -export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { +export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React.ReactElement { const { columns, rows } = useWindowSize(); // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) @@ -20,10 +21,10 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement setViewMode("server-list"); }, []); - // 进入服务器详情 + // 进入服务器详情(允许 ready、failed、reconnecting 状态) const enterDetail = useCallback(() => { const server = statuses[selectedServerIndex]; - if (server && server.status === "ready") { + if (server && (server.status === "ready" || server.status === "failed" || server.status === "reconnecting")) { setViewMode("server-detail"); } }, [statuses, selectedServerIndex]); @@ -59,6 +60,7 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement server={statuses[selectedServerIndex]} onBack={goBack} onCancel={onCancel} + onReconnect={onReconnect} rows={rows} columns={columns} /> @@ -173,6 +175,7 @@ function ServerListView({ 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 ( @@ -198,6 +201,11 @@ function ServerListView({ {startingCount} starting, + {reconnectingCount > 0 && ( + + {reconnectingCount} reconnecting, + + )} {failedCount} failed @@ -257,15 +265,23 @@ function ServerRow({ selected: boolean; labelColumnWidth: number; }): React.ReactElement { - const icon = status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : "●"; - const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow"; + 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") return; + if (status.status !== "starting" && status.status !== "reconnecting") return; const interval = setInterval(() => { - setDots((d) => (d + 1) % 4); // 0 → 1 → 2 → 3 → 0 ... + setDots((d) => (d + 1) % 4); }, 500); return () => clearInterval(interval); }, [status.status]); @@ -275,7 +291,9 @@ function ServerRow({ ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` : status.status === "failed" ? `Failed` - : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ... + : status.status === "reconnecting" + ? `Reconnecting${dots > 0 ? ".".repeat(dots) : " "}` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); return ( @@ -293,8 +311,10 @@ function ServerRow({ - {/* Error message for failed servers */} - {status.status === "failed" && status.error ? : null} + {/* Error message for failed or reconnecting servers */} + {(status.status === "failed" || status.status === "reconnecting") && status.error ? ( + + ) : null} ); } @@ -304,59 +324,54 @@ 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] = useState(0); + const [activeIndex, setActiveIndex] = React.useState(0); + const hasReconnect = server.status === "failed"; + const canScroll = server.status === "ready"; - // 合并所有 items(tools, prompts, resources) + // 合并所有 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]); + }, [server, hasReconnect]); const totalItems = allItems.length; const maxVisible = useMemo(() => { - const reservedLines = 10; // header + title + stats + footer + borders + 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]); - // 使用 ref 跟踪 visibleStart,避免循环依赖 const visibleStartRef = React.useRef(0); - // 计算可见窗口起始位置:当 activeIndex 超出可见区域时才滚动(类似终端光标行为) const visibleStart = useMemo(() => { if (totalItems === 0) return 0; - const currentStart = visibleStartRef.current; let newStart = currentStart; - - // 如果 activeIndex 在当前可见窗口之前,滚动到 activeIndex if (activeIndex < currentStart) { newStart = activeIndex; - } - // 如果 activeIndex 在当前可见窗口之后,滚动到 activeIndex - else if (activeIndex >= currentStart + maxVisible) { + } else if (activeIndex >= currentStart + maxVisible) { newStart = activeIndex - maxVisible + 1; } - - // 限制在合法范围内 newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); - - // 更新 ref visibleStartRef.current = newStart; - return newStart; }, [activeIndex, maxVisible, totalItems]); @@ -371,11 +386,16 @@ function ServerDetailView({ onBack(); return; } - // Space 或 Enter 键返回一级菜单 - if (input === " " || key.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; @@ -384,25 +404,33 @@ function ServerDetailView({ setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1)); return; } - if (key.pageUp) { + if (key.pageUp && canScroll) { setActiveIndex((prev) => Math.max(0, prev - maxVisible)); return; } - if (key.pageDown) { + if (key.pageDown && canScroll) { setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible)); return; } - if (key.home) { + if (key.home && canScroll) { setActiveIndex(0); return; } - if (key.end) { + if (key.end && canScroll) { setActiveIndex(totalItems - 1); } }); - const icon = "✓"; - const color = "green"; + 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 */} - {icon} + {statusIcon} {server.name} - — Details + — {server.status === "ready" ? "Details" : "Status"} {/* Server info */} - {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources + {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 */} {/* Footer */} - ↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close + + {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"} + @@ -481,13 +523,16 @@ function ServerDetailView({ } function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { - const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + 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} - - {item.name} + + {isAction ? `[${item.name}]` : item.name} ); diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx deleted file mode 100644 index c8793fc..0000000 --- a/src/ui/MessageView.tsx +++ /dev/null @@ -1,355 +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; - width?: number; -}; - -export function MessageView({ message, collapsed, width = 80 }: 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} - - - ); - } - - const containerWidth = Math.max(1, width - 2); - const contentWidth = Math.max(1, width - 4); - - 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") { - // 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, -}: { - 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 3965144..8c808e9 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,13 +1,21 @@ 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, @@ -30,28 +38,49 @@ import { } 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, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; -import SlashCommandMenu from "./SlashCommandMenu"; -import type { ModelConfigSelection, ReasoningEffort } from "../settings"; -import DropdownMenu from "./DropdownMenu"; +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" | "mcp" | "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; @@ -60,27 +89,16 @@ type Props = { 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; - -type ThinkingModeOption = { - label: string; - thinkingEnabled: boolean; - reasoningEffort?: ReasoningEffort; -}; - -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 }, -]; - -type ModelDropdownStep = "model" | "thinking"; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); @@ -101,6 +119,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }); export const PromptInput = React.memo(function PromptInput({ + projectRoot, skills, modelConfig, screenWidth, @@ -109,9 +128,13 @@ export const PromptInput = React.memo(function PromptInput({ loadingText, disabled, placeholder, + runningProcesses, + promptDraft, onSubmit, onModelConfigChange, onInterrupt, + onToggleProcessStdout, + onRawModeChange, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); @@ -122,36 +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 [modelDropdownStep, setModelDropdownStep] = useState(null); - const [modelDropdownIndex, setModelDropdownIndex] = useState(0); - const [pendingModel, setPendingModel] = useState(null); + 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 || modelDropdownStep ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, modelDropdownStep, 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); @@ -163,21 +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]); - - useEffect(() => { - if (!modelDropdownStep) { - return; - } - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (modelDropdownIndex >= optionCount) { - setModelDropdownIndex(Math.max(0, optionCount - 1)); - } - }, [modelDropdownIndex, modelDropdownStep]); + }, [fileMentionKey]); useEffect(() => { if (!statusMessage) { @@ -187,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); @@ -208,12 +295,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { - if (modelDropdownStep) { - closeModelDropdown(); - return; - } - if (showSkillsDropdown) { - setShowSkillsDropdown(false); + if (showFileMentionMenu) { return; } if (busy) { @@ -223,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)); @@ -246,6 +337,8 @@ export const PromptInput = React.memo(function PromptInput({ } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); clearUndoRedoStacks(); + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -256,55 +349,17 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { - exitHistoryBrowsing(); + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + return; } - 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 (historyCursor !== -1 && !key.upArrow && !key.downArrow) { + exitHistoryBrowsing(); } - if (modelDropdownStep) { - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (key.upArrow) { - setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount); - return; - } - if (key.downArrow) { - setModelDropdownIndex((idx) => (idx + 1) % optionCount); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - selectModelDropdownItem(); - return; - } - if (key.tab) { - closeModelDropdown(); - return; - } + if (key.paste) { + handlePaste(input); + return; } if (key.ctrl && (input === "v" || input === "V")) { @@ -338,6 +393,12 @@ export const PromptInput = React.memo(function PromptInput({ 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) { setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); @@ -372,12 +433,12 @@ export const PromptInput = React.memo(function PromptInput({ } 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; } @@ -467,6 +528,8 @@ 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")) { @@ -544,6 +607,81 @@ export const PromptInput = React.memo(function PromptInput({ }); } + 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 { if (promptHistory.length === 0) { return; @@ -570,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"); @@ -589,43 +746,43 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "model") { clearSlashToken(); - openModelDropdown(); + 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); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "init") { onSubmit(buildInitPromptSubmission(selectedSkills)); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - 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" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "exit") { @@ -656,15 +813,11 @@ export const PromptInput = React.memo(function PromptInput({ } onSubmit({ - text: buffer.text, + text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, selectedSkills, }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); } function addSelectedSkill(skill: SkillInfo): void { @@ -681,65 +834,14 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); } - function openModelDropdown(): void { - const currentModelIndex = MODEL_COMMAND_MODELS.findIndex((model) => model === modelConfig.model); - setPendingModel(null); - setModelDropdownStep("model"); - setModelDropdownIndex(currentModelIndex >= 0 ? currentModelIndex : 0); - setShowSkillsDropdown(false); - } - - function closeModelDropdown(): void { - setModelDropdownStep(null); - setPendingModel(null); - } - - function selectModelDropdownItem(): void { - if (modelDropdownStep === "model") { - const model = MODEL_COMMAND_MODELS[modelDropdownIndex] ?? modelConfig.model; - setPendingModel(model); - setModelDropdownStep("thinking"); - setModelDropdownIndex(getThinkingOptionIndex(modelConfig)); - return; - } - - const option = MODEL_COMMAND_THINKING_OPTIONS[modelDropdownIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]; - const selection: ModelConfigSelection = { - model: pendingModel ?? modelConfig.model, - thinkingEnabled: option.thinkingEnabled, - reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, - }; - closeModelDropdown(); - Promise.resolve(onModelConfigChange(selection)) - .then((message) => { - if (message) { - setStatusMessage(message); - } - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusMessage(`Failed to update model settings: ${message}`); - }); - } - - const modelDropdownItems = - modelDropdownStep === "model" - ? MODEL_COMMAND_MODELS.map((model) => ({ - label: model, - selected: model === (pendingModel ?? modelConfig.model), - description: model === modelConfig.model ? "current model" : "", - })) - : MODEL_COMMAND_THINKING_OPTIONS.map((option) => ({ - label: option.label, - selected: getThinkingOptionIndex(modelConfig) === MODEL_COMMAND_THINKING_OPTIONS.indexOf(option), - description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", - })); - const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || modelDropdownStep !== null, - [showMenu, showSkillsDropdown, modelDropdownStep] + () => 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 ( {imageUrls.length > 0 ? ( @@ -766,46 +868,43 @@ export const PromptInput = React.memo(function PromptInput({ borderDimColor > - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + {inlineHint ? {inlineHint} : null} - {showSkillsDropdown ? ( - ({ - 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} - /> - ) : null} - {modelDropdownStep ? ( - onRawModeChange?.(mode)} + screenWidth={screenWidth} + /> + + setShowModelDropdown(false)} + onModelConfigChange={onModelConfigChange} + onStatusMessage={setStatusMessage} + /> + { + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); } - items={modelDropdownItems.map((item) => ({ - key: item.label, - label: item.label, - description: item.description, - selected: item.selected, - }))} - activeIndex={modelDropdownIndex} - activeColor="#229ac3" - maxVisible={6} - /> - ) : null} + }} + onSelect={insertFileMentionSelection} + /> {!showFooterText && ( @@ -833,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; @@ -856,18 +951,6 @@ export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSu }; } -export 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; -} - export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { @@ -899,12 +982,15 @@ export function getPromptReturnKeyAction(key: Pick +): 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) { @@ -913,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); } - if (typeof at === "undefined") { - return before + renderCursorCell(" "); + 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; + } + + // 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 fdbd1fe..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 436be83..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,7 +11,9 @@ 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, @@ -21,7 +25,9 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ if (items.length === 0) { return 0; } - const longestLabel = Math.max(...items.map((s) => s.label.length)); + 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); @@ -49,11 +55,12 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ const actualIndex = visibleStart + idx; return ( - + {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 3d82eed..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; 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/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 5b4ff8f..1348903 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,21 +1,28 @@ +import { + getThinkingOptionIndex, + MODEL_COMMAND_MODELS, + MODEL_COMMAND_THINKING_OPTIONS, +} from "./components/ModelsDropdown"; + export { - App, readSettings, readProjectSettings, writeSettings, writeProjectSettings, writeModelConfigSelection, resolveCurrentSettings, - createOpenAIClient, + 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, @@ -23,16 +30,16 @@ export { getPromptReturnKeyAction, renderBufferWithCursor, buildInitPromptSubmission, - getThinkingOptionIndex, - MODEL_COMMAND_MODELS, - MODEL_COMMAND_THINKING_OPTIONS, useTerminalInput, parseTerminalInput, + dispatchTerminalInput, type PromptSubmission, + type PromptDraft, type InputKey, } from "./PromptInput"; +export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; -export { SessionList, formatSessionTitle } from "./SessionList"; +export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen"; @@ -47,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, @@ -79,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 2668470..aefea34 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,6 +40,14 @@ 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"; } @@ -260,3 +268,16 @@ export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, }; }, [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 a33172c..6435f62 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,9 +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 8013ff6..e3d6349 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -20,6 +20,8 @@ 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"]); @@ -35,6 +37,13 @@ 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 @@ -73,6 +82,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: false, focusIn: false, focusOut: false, + paste: false, }; return { input, key }; } @@ -100,6 +110,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: false, focusIn: false, focusOut: false, + paste: false, }; return { input, key }; } @@ -123,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) { @@ -169,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 } = {} @@ -178,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); @@ -192,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 3e3c182..3e0a710b 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -171,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/slashCommands.ts b/src/ui/slashCommands.ts index 16a76ac..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" | "model" | "new" | "init" | "resume" | "mcp" | "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[] = [ @@ -41,12 +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/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/templates/tools/bash.md b/templates/tools/bash.md index 0705120..83027d3 100644 --- a/templates/tools/bash.md +++ b/templates/tools/bash.md @@ -28,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: @@ -60,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/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 +} +```