From b010fb16f92dd01444d683566e41c3ef08c72af7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 21 May 2026 22:46:17 +0800 Subject: [PATCH 01/21] =?UTF-8?q?docs(readme):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=AE=B8=E5=8F=AF=E8=AF=81=E9=93=BE=E6=8E=A5=E5=88=B0=E4=B8=BB?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 README.md 中许可证链接从 master 更改为 main 分支路径 - 同步更新 README-en.md 中的许可证链接路径 - 同步更新 README-zh_CN.md 中的许可证链接路径 - 保持徽章和其他链接不变,确保一致性和正确性 --- README-en.md | 2 +- README-zh_CN.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README-en.md b/README-en.md index be6442b..1e1323d 100644 --- a/README-en.md +++ b/README-en.md @@ -195,5 +195,5 @@ If you find this tool helpful, please consider supporting us by: [github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square [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 -[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[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 \ No newline at end of file diff --git a/README-zh_CN.md b/README-zh_CN.md index 52f0123..2909271 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -193,5 +193,5 @@ npm link [github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square [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 -[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[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 \ No newline at end of file diff --git a/README.md b/README.md index 52f0123..2909271 100644 --- a/README.md +++ b/README.md @@ -193,5 +193,5 @@ npm link [github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square [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 -[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/master/LICENSE +[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 \ No newline at end of file From c4a2463847d1d294624199d4a066b44b2547df37 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 08:42:47 +0800 Subject: [PATCH 02/21] feat: update README.md --- README-en.md | 16 ++++++++-------- README-zh_CN.md | 16 ++++++++-------- README.md | 16 ++++++++-------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README-en.md b/README-en.md index 1e1323d..4bff6af 100644 --- a/README-en.md +++ b/README-en.md @@ -182,18 +182,18 @@ If you find this tool helpful, please consider supporting us by: [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 +[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 +[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 +[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 +[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 +[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 +[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 +[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 \ No newline at end of file +[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 index 2909271..77db497 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -180,18 +180,18 @@ npm link [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 +[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 +[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 +[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 +[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 +[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 +[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 +[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 \ No newline at end of file +[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 2909271..77db497 100644 --- a/README.md +++ b/README.md @@ -180,18 +180,18 @@ npm link [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 +[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 +[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 +[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 +[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 +[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 +[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 +[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 \ No newline at end of file +[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 From d7d453f55bd11352a38dadb40ebb375cb656e9ba Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 09:20:55 +0800 Subject: [PATCH 03/21] chore: remove draft doc --- docs/SKILL_new.md | 246 ---------------------------------------------- 1 file changed, 246 deletions(-) delete mode 100644 docs/SKILL_new.md diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md deleted file mode 100644 index 9fc8bd2..0000000 --- a/docs/SKILL_new.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -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. From 27b9b7feb444cbeb0b10473216e7f6804530234f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 10:22:12 +0800 Subject: [PATCH 04/21] refactor: adjust calling identifyMatchingSkillNames in createSession and replySession --- src/session.ts | 37 +++++++++++++++++++------------------ src/tests/session.test.ts | 10 ++++++++-- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/session.ts b/src/session.ts index 54340e7..3144f88 100644 --- a/src/session.ts +++ b/src/session.ts @@ -901,20 +901,6 @@ 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(); @@ -977,6 +963,21 @@ The candidate skills are as follows:\n\n`; 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) { @@ -1022,6 +1023,10 @@ ${skillMd} 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 }); @@ -1037,10 +1042,6 @@ ${skillMd} userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); this.throwIfAborted(signal); - this.ensureFileHistorySession(sessionId); - 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) { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index fd83199..e5bdcb2 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1952,7 +1952,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); @@ -1981,7 +1981,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 () => { From f1774292a0e2e4420117e1984ce40efd94b38799 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 11:01:31 +0800 Subject: [PATCH 05/21] feat: implement checkpoints store only explicit Write/Edit file paths --- src/common/file-history.ts | 206 ++++++++++++++++++++++++++++--------- src/tests/session.test.ts | 96 +++++++++-------- 2 files changed, 215 insertions(+), 87 deletions(-) diff --git a/src/common/file-history.ts b/src/common/file-history.ts index d5966d9..2a41d9a 100644 --- a/src/common/file-history.ts +++ b/src/common/file-history.ts @@ -1,13 +1,26 @@ 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( - private readonly projectRoot: string, + _projectRoot: string, private readonly gitDir: string ) {} @@ -20,7 +33,7 @@ export class GitFileHistory { try { if (!fs.existsSync(this.gitDir)) { fs.mkdirSync(path.dirname(this.gitDir), { recursive: true }); - this.runGit(["init"], { includeWorkTree: true }); + this.runGit(["init"]); } const current = this.getCurrentCheckpointHash(sessionId); @@ -28,9 +41,9 @@ export class GitFileHistory { return current; } - const emptyTree = this.runGit(["mktree"], { includeWorkTree: false, input: "" }).trim(); - const commitHash = this.createCommit(emptyTree, null, "Initial checkpoint"); - this.runGit(["update-ref", branchRef, commitHash], { includeWorkTree: false }); + const treeHash = this.createTree(emptyManifest()); + const commitHash = this.createCommit(treeHash, null, "Initial checkpoint"); + this.runGit(["update-ref", branchRef, commitHash]); return commitHash; } catch { return undefined; @@ -44,9 +57,7 @@ export class GitFileHistory { } try { - const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`], { - includeWorkTree: false, - }).trim(); + const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); return isCommitHash(hash) ? hash : undefined; } catch { return undefined; @@ -59,10 +70,8 @@ export class GitFileHistory { return undefined; } - const relativePaths = filePaths - .map((filePath) => this.toProjectRelativeGitPath(filePath)) - .filter((filePath): filePath is string => Boolean(filePath)); - if (relativePaths.length === 0) { + const absolutePaths = uniqueAbsolutePaths(filePaths); + if (absolutePaths.length === 0) { return this.getCurrentCheckpointHash(sessionId); } @@ -71,18 +80,30 @@ export class GitFileHistory { if (!parentHash) { return undefined; } - this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); - this.runGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true }); - const treeHash = this.runGit(["write-tree"], { includeWorkTree: false }).trim(); - const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`], { - includeWorkTree: false, - }).trim(); + + 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], { includeWorkTree: false }); + this.runGit(["update-ref", branchRef, commitHash, parentHash]); return commitHash; } catch { return undefined; @@ -101,7 +122,8 @@ export class GitFileHistory { } try { - this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false }); + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); + this.readManifest(checkpointHash); return true; } catch { return false; @@ -116,16 +138,24 @@ export class GitFileHistory { 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}`], { includeWorkTree: false }); + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); - try { - this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true }); - } catch { - // If the session branch is missing, fall back to the target tree only. - // The target checkpoint has already been validated above. + 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); + } } - this.runGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true }); - this.runGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false }); + + 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 { @@ -142,41 +172,125 @@ export class GitFileHistory { } args.push("-m", message); return this.runGit(args, { - includeWorkTree: false, env: getFileHistoryGitEnv(), }).trim(); } - private toProjectRelativeGitPath(filePath: string): string | null { - const absolutePath = path.resolve(filePath); - const relativePath = path.relative(this.projectRoot, absolutePath); - if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return null; + 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 relativePath.split(path.sep).join("/"); + + return this.runGit(["mktree", "-z"], { input: entries.join("") }).trim(); } - private runGit( - args: string[], - options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv } - ): string { - const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`]; - if (options.includeWorkTree) { - gitArgs.push(`--work-tree=${this.projectRoot}`); + 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."); } - gitArgs.push(...args); + 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: "utf8", + encoding, input: options.input, env: options.env, stdio: ["pipe", "pipe", "pipe"], }); if (result.status !== 0) { - const detail = (result.stderr || result.stdout || "").trim(); + 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 ?? ""; + 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 { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index e5bdcb2..08d61e9 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -4,6 +4,7 @@ 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; @@ -1040,6 +1041,54 @@ test("Write tool advances file-history while preserving the user prompt checkpoi 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-"); @@ -2146,43 +2195,18 @@ function createFileHistoryCommit( ): string { const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); const gitDir = path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git"); - const branchRef = `refs/heads/${sessionId}`; - fs.mkdirSync(path.dirname(gitDir), { recursive: true }); - if (!fs.existsSync(gitDir)) { - runFileHistoryGit(gitDir, workspace, ["init"]); - } - - let parentHash = ""; - try { - parentHash = runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); - } catch { - const emptyTree = runFileHistoryGit(gitDir, workspace, ["mktree"], ""); - parentHash = runFileHistoryGit( - gitDir, - workspace, - ["commit-tree", emptyTree.trim(), "-m", "initial checkpoint"], - "", - fileHistoryCommitEnv() - ).trim(); - runFileHistoryGit(gitDir, workspace, ["update-ref", branchRef, parentHash]); - } - runFileHistoryGit(gitDir, workspace, ["read-tree", "--reset", branchRef]); + 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); } - runFileHistoryGit(gitDir, workspace, ["add", "-f", "-A", "--", ...Object.keys(files)]); - const treeHash = runFileHistoryGit(gitDir, workspace, ["write-tree"]).trim(); - const commitHash = runFileHistoryGit( - gitDir, - workspace, - ["commit-tree", treeHash, "-p", parentHash, "-m", "checkpoint"], - "", - fileHistoryCommitEnv() - ).trim(); - runFileHistoryGit(gitDir, workspace, ["update-ref", branchRef, commitHash, parentHash]); + const commitHash = fileHistory.recordCheckpoint(sessionId, filePaths, "checkpoint"); + assert.ok(commitHash); return commitHash; } @@ -2205,16 +2229,6 @@ function runFileHistoryGit( ); } -function fileHistoryCommitEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - GIT_AUTHOR_NAME: "DeepCode Test", - GIT_AUTHOR_EMAIL: "deepcode-test@example.com", - GIT_COMMITTER_NAME: "DeepCode Test", - GIT_COMMITTER_EMAIL: "deepcode-test@example.com", - }; -} - function createSessionManager(projectRoot: string, machineId: string): SessionManager { return new SessionManager({ projectRoot, From 683a51106b1d9faa8d58811da80f5857eb641934 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 21:41:14 +0800 Subject: [PATCH 06/21] feat: implement the permission system --- docs/issue_0522.md | 241 +++++++++++++ src/common/permissions.ts | 464 ++++++++++++++++++++++++++ src/prompt.ts | 23 +- src/session.ts | 229 +++++++++++-- src/settings.ts | 97 ++++++ src/tests/permissions.test.ts | 120 +++++++ src/tests/prompt.test.ts | 13 + src/tests/session.test.ts | 192 +++++++++++ src/tests/settings-and-notify.test.ts | 29 ++ src/ui/App.tsx | 74 +++- src/ui/PermissionPrompt.tsx | 229 +++++++++++++ src/ui/PromptInput.tsx | 4 +- templates/tools/bash.md | 28 +- 13 files changed, 1719 insertions(+), 24 deletions(-) create mode 100644 docs/issue_0522.md create mode 100644 src/common/permissions.ts create mode 100644 src/tests/permissions.test.ts create mode 100644 src/ui/PermissionPrompt.tsx diff --git a/docs/issue_0522.md b/docs/issue_0522.md new file mode 100644 index 0000000..2e9fd1a --- /dev/null +++ b/docs/issue_0522.md @@ -0,0 +1,241 @@ +# Deep Code Permission System (设计文档) + +scopes是枚举值,列表如下: + +``` +# 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 +``` + +settings.json的配置项(例子): + +``` +{ + "permissions": { + "allow": [ + "write-in-cwd" + ], + "deny": [ + "write-out-cwd" + ], + "ask": [ + "read-out-cwd" + ], + "defaultMode": "allowAll|askAll" // 默认是allowAll + } +} +``` + +工具和PermissionScope可能的对应关系: + +- read: read-in-cwd, read-out-cwd +- write: write-in-cwd, write-out-cwd +- edit: write-in-cwd, write-out-cwd +- WebSearch: network +- mcp__*: mcp +- bash: 每一次bash命令需要的scope在sideEffects字段中。如果sideEffects字段为undefined|null,或者sideEffects包含了"unknown"则总是ask +- 其他: 无权限要求,总是允许 + +## bash tool的参数schema新增sideEffects字段 + +目标:让LLM在每一次调用`bash`时显式声明该命令可能需要的权限范围,后端只信任这个结构化字段,不从自然语言`description`中推断权限。 + +需要同步修改两处schema: + +1. `src/prompt.ts`里的`getTools()`内置`bash`工具定义。 +2. `templates/tools/bash.md`里的`bash`工具说明和JSON schema示例。 + +新增字段: + +``` +sideEffects: PermissionScope[] | ["unknown"] +``` + +`bash`可声明的scope只包含文件系统、Git历史和网络权限,不包含`mcp`: + +``` +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 +``` + +建议schema如下: + +```json +{ + "type": "object", + "properties": { + "command": { + "description": "The command to execute", + "type": "string" + }, + "description": { + "description": "Clear, concise description of what this command does in active voice.", + "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", + "sideEffects" + ], + "additionalProperties": false +} +``` + +字段语义: + +- `sideEffects: []`表示命令不需要权限,例如`date`、`node --version`这类只读取进程环境或输出版本信息的命令。 +- `sideEffects`必须按最小必要权限填写;例如`rg foo src`是`["read-in-cwd"]`,`npm install`通常是`["write-in-cwd", "network"]`。 +- 如果命令访问项目目录之外的路径,需要使用`*-out-cwd`;例如`cat /etc/hosts`是`["read-out-cwd"]`。 +- 删除类操作使用`delete-*`;如果同一条命令还会写入其他文件,再同时声明对应的`write-*`。 +- 查询Git历史使用`query-git-log`;例如`git log`、`git show HEAD`、`git blame`、`git diff HEAD~1..HEAD`这类读取提交历史、提交对象或历史diff的命令。 +- 修改Git历史或引用使用`mutate-git-log`;例如`git commit`、`git reset`、`git rebase`、`git merge`、`git cherry-pick`、`git tag`这类会创建提交、移动引用或改写提交图的命令。 +- Git命令如果同时读写工作区文件,也需要同时声明文件系统scope;例如`git checkout -- src/foo.ts`需要`["write-in-cwd"]`,`git reset --hard HEAD~1`需要`["write-in-cwd", "mutate-git-log"]`。 +- `unknown`只能单独出现为`["unknown"]`,不能和其他scope混用。 + +示例: + +```json +{ "command": "date", "description": "Show current date", "sideEffects": [] } +{ "command": "rg \"TODO\" src", "description": "Search TODO markers in source files", "sideEffects": ["read-in-cwd"] } +{ "command": "npm install", "description": "Install package dependencies", "sideEffects": ["write-in-cwd", "network"] } +{ "command": "rm -rf dist", "description": "Delete build output directory", "sideEffects": ["delete-in-cwd"] } +{ "command": "curl -s https://example.com", "description": "Fetch example.com response", "sideEffects": ["network"] } +{ "command": "git show --stat HEAD", "description": "Show file statistics for HEAD", "sideEffects": ["query-git-log"] } +{ "command": "git blame src/prompt.ts", "description": "Show line authorship for prompt source", "sideEffects": ["read-in-cwd", "query-git-log"] } +{ "command": "git reset --hard HEAD~1", "description": "Reset branch and worktree to previous commit", "sideEffects": ["write-in-cwd", "mutate-git-log"] } +``` + +## 核心数据结构设计 + +``` +export type UserPromptContent = { + text?: string; + imageUrls?: string[]; + skills?: SkillInfo[]; ++ permissions?: [{toolCallId: "...", permission: "allow|deny"}]; ++ alwaysAllows?: [""]; +}; + +export type SessionEntry = { + id: string; + ... + toolCalls: unknown[] | null; // 例如:[{"id":"...","function":{"name":"bash","arguments":"{\"command\": \"...\", \"description\": \"...\"}"}}] + status: SessionStatus; ++ askPermissions?: [{toolCallId: "...", scopes: [""], name: "...", command: "...", description?: "..."}]; +}; + +export type SessionStatus = "... | "completed" | "interrupted" | "ask_permission"; // 新增 ask_permission 状态 + +export type SessionMessage = { + ... + meta?: MessageMeta; + ... +}; + +export type MessageMeta = { + ... ++ permissions?: [{toolCallId: "...", permission: "allow|deny|ask"}]; ++ userPrompt?: UserPromptContent; //对于role为user的消息,持久化userPrompt可方便后续排查问题 +}; +``` + +## 前端流程 + +如果当前会话状态不是ask_permission,则保持现状。会话状态是ask_permission时: + +对SessionEntry.askPermissions中每一个toolCallId的每一个scope,显示权限弹窗(示例): + +``` + + + + + + Do you want to proceed? + ❯ 1. Yes + 2. Yes, and always allow + 3. No +``` + +注意对于read/write/edit的``,格式可以是"工具名称+相对或绝对文件路径",例如:`read ~/dev/main.c` + +如果在权限弹窗过程中,用户按Esc,则走现有的interrupt流程(会话状态也应该变成"interrupted")。 + +提醒注意一种情况:例如askPermissions里面有好几个item的scopes是`["write-in-cwd"]`,如果用户已经在第一个权限弹窗选了"always allow write in CWD `~/dev/qrcode_test/`",则后面的几个scopes是`["write-in-cwd"]`的item就不用显示权限弹窗了。 + +如果用户完成了所有权限弹窗的选择,则判断: + +1. 如果用户提交的结果中包含deny,则需要用户输入user prompt,按回车手动提交replySession()。 + - 如果用户没有输入user prompt就退出了,或者切换到了其他会话。则重新开始这个会话时,由于会话状态还是ask_permission,则会重新显示权限弹窗,要求用户选择。 +2. 如果用户提交的结果中不包含deny,则以`/continue`作为UserPromptContent.text内容,前端自动提交replySession()。 + + +## 后端流程 + +后端主要是对replySession()和activateSession()进行升级: + +1. 支持传入UserPromptContent.permissions和alwaysAllows +2. 如果UserPromptContent.alwaysAllows非空,将其中的scopes追加写入项目级别的settings.json配置文件(`permissions.allow`字段),避免重复写入已存在的项。 +3. 检查当前会话消息列表末尾是否存在连续的role为assistant的有tool_calls的消息,也就是"待执行消息"。如果没有,则走现有流程。 +4. 对于每一条待执行消息,先检查UserPromptContent.permissions中对应的toolCallId的用户授权是allow还是deny + - 如果是allow,则正常执行这个toolCall + - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限。例如: + ``` + { + "ok": false, + "name": "edit", + "error": "用户已禁用了在项目目录之外修改文件的权限,请不要尝试用任何方式修改目录之外的文件" + } + ``` +5. 如果对于某条待执行消息,在UserPromptContent.permissions没有出现对应的toolCallId的用户授权,则检查它的 SessionMessage.meta.permissions[].permission 是allow还是deny还是ask + - 如果是allow,则正常执行这个toolCall + - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限 + - 如果是ask,则直接返回失败结果,报错信息提示LLM用户未授权执行。例如: + ``` + { + "ok": false, + "name": "edit", + "error": "用户暂未授权执行,如果有必要,可重新尝试执行" + } + ``` + - 如果不存在,则正常执行这个toolCall(兼容老版本会话数据) +6. 当LLM返回了新的待执行消息时,不要立即执行,而是: + 1. 根据配置的permissions和defaultMode,计算出SessionMessage.meta.permissions字段 + 2. 如果存在一个待执行消息的SessionMessage.meta.permissions[].permission是ask,则把SessionEntry.status设置为"ask_permission",并设置好SessionEntry.askPermissions,然后退出activateSession,这样就回到了上面的前端流程。 diff --git a/src/common/permissions.ts b/src/common/permissions.ts new file mode 100644 index 0000000..e9aae01 --- /dev/null +++ b/src/common/permissions.ts @@ -0,0 +1,464 @@ +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") { + askPermissions.push({ + toolCallId: toolCall.id, + scopes: 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 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): 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 currentAllow = Array.isArray(settings.permissions?.allow) ? settings.permissions.allow : []; + const allow = [...currentAllow]; + for (const scope of nextScopes) { + if (!allow.includes(scope)) { + allow.push(scope); + } + } + if (allow.length === currentAllow.length) { + return; + } + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + `${JSON.stringify( + { + ...settings, + permissions: { + ...(settings.permissions ?? {}), + 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/prompt.ts b/src/prompt.ts index 717991b..ba9bf23 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -331,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, }, }, diff --git a/src/session.ts b/src/session.ts index 3144f88..c5da055 100644 --- a/src/session.ts +++ b/src/session.ts @@ -22,13 +22,38 @@ import { 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"; @@ -127,7 +152,14 @@ 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"; export type ModelUsage = { prompt_tokens: number; @@ -170,6 +202,7 @@ export type SessionEntry = { createTime: string; updateTime: string; processes: Map | null; // {pid: process info} + askPermissions?: AskPermissionRequest[]; }; export type SessionsIndex = { @@ -188,6 +221,8 @@ export type MessageMeta = { isSummary?: boolean; isModelChange?: boolean; skill?: SkillInfo; + permissions?: MessageToolPermission[]; + userPrompt?: UserPromptContent; }; export type SessionMessage = { @@ -216,6 +251,8 @@ export type UserPromptContent = { text?: string; imageUrls?: string[]; skills?: SkillInfo[]; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; }; export type SkillInfo = { @@ -228,7 +265,12 @@ 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; @@ -253,6 +295,7 @@ 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; @@ -1002,11 +1045,13 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, + askPermissions: undefined, updateTime: now, })); @@ -1015,9 +1060,15 @@ ${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); + await this.activateSession(sessionId, controller, userPrompt); return; } @@ -1070,7 +1121,11 @@ ${skillMd} ); } - async activateSession(sessionId: string, controller?: AbortController): Promise { + async activateSession( + sessionId: string, + controller?: AbortController, + permissionPrompt?: UserPromptContent + ): Promise { const startedAt = Date.now(); const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); @@ -1129,16 +1184,20 @@ ${skillMd} return; } - const pendingToolCalls = this.getTrailingPendingToolCalls(this.listSessionMessages(sessionId)); - if (pendingToolCalls.length > 0) { - const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCalls); + 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: pendingToolCalls, + toolCalls: pendingToolCallMessage.toolCalls, status: "waiting_for_user", updateTime: new Date().toISOString(), })); @@ -1192,12 +1251,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; } @@ -1205,7 +1299,6 @@ ${skillMd} return; } - const responseUsage = response.usage ?? null; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -1217,6 +1310,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(), })); @@ -1768,6 +1862,7 @@ ${skillMd} visible: true, createTime: now, updateTime: now, + meta: { userPrompt: this.cloneUserPromptForMeta(prompt) }, checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } @@ -1957,8 +2052,15 @@ ${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), @@ -1966,7 +2068,23 @@ ${skillMd} 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 }; } @@ -1997,6 +2115,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, @@ -2125,18 +2309,23 @@ ${skillMd} return pairings; } - private getTrailingPendingToolCalls(messages: SessionMessage[]): unknown[] { + 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 []; + return { message: null, toolCalls: [] }; } const toolCalls = this.getAssistantToolCalls(latestMessage); if (toolCalls.length === 0) { - return []; + return { message: null, toolCalls: [] }; } - return toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))); + return { + message: latestMessage, + toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))), + }; } private findPairableToolMessageIndex( @@ -2490,6 +2679,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), }; } @@ -2500,7 +2690,8 @@ ${skillMd} status === "processing" || status === "waiting_for_user" || status === "completed" || - status === "interrupted" + status === "interrupted" || + status === "ask_permission" ) { return status; } 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/permissions.test.ts b/src/tests/permissions.test.ts new file mode 100644 index 0000000..adb5388 --- /dev/null +++ b/src/tests/permissions.test.ts @@ -0,0 +1,120 @@ +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("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("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/prompt.test.ts b/src/tests/prompt.test.ts index cc86712..953de7c 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -19,6 +19,19 @@ test("getTools includes UpdatePlan with string plan schema", () => { 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); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 08d61e9..b3c5de9 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1256,6 +1256,162 @@ test("replySession /continue runs trailing pending tool calls before requesting ); }); +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("replySession applies permission replies, runs pending tools, and stores always allow scopes", async () => { + const workspace = createTempDir("deepcode-permission-allow-workspace-"); + const home = createTempDir("deepcode-permission-allow-home-"); + setHomeDir(home); + fs.writeFileSync(path.join(workspace, "note.txt"), "allowed content\n", "utf8"); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("continued", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["read-in-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read", + [ + { + id: "call-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-read", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "/continue", + permissions: [{ toolCallId: "call-read", permission: "allow" }], + alwaysAllows: ["read-in-cwd"], + }); + + const toolMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "tool"); + const settings = JSON.parse(fs.readFileSync(path.join(workspace, ".deepcode", "settings.json"), "utf8")); + + assert.match(toolMessage?.content ?? "", /allowed content/); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd"]); + assert.equal(manager.getSession(sessionId)?.status, "completed"); +}); + +test("replySession turns denied permission replies into tool errors before appending user text", async () => { + const workspace = createTempDir("deepcode-permission-deny-workspace-"); + const home = createTempDir("deepcode-permission-deny-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("handled denial", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["write-out-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to write", + [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/outside.txt", content: "x" }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-write", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "Do not write outside the workspace.", + permissions: [{ toolCallId: "call-write", permission: "deny" }], + }); + + const messages = manager.listSessionMessages(sessionId); + const assistantIndex = messages.findIndex((message) => message.id === assistant.id); + const toolMessage = messages[assistantIndex + 1]; + const userMessage = messages[assistantIndex + 2]; + + assert.equal(toolMessage?.role, "tool"); + assert.match(toolMessage?.content ?? "", /User denied the required permission/); + assert.equal(userMessage?.role, "user"); + assert.equal(userMessage?.content, "Do not write outside the workspace."); +}); + test("replySession preserves raw session messages when a previous tool call is pending", async () => { const workspace = createTempDir("deepcode-pending-tool-workspace-"); const home = createTempDir("deepcode-pending-tool-home-"); @@ -2315,6 +2471,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, diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 1707aff..52f8671 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -147,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( { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5419a2a..c8c24f1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import { createOpenAIClient } from "../common/openai-client"; import { type LlmStreamProgress, type MessageMeta, + type PermissionScope, type SessionEntry, SessionManager, type SessionMessage, @@ -38,6 +39,7 @@ import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, } from "./askUserQuestion"; +import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; import { buildExitSummaryText } from "./exitSummary"; import { RawMode, useRawModeContext } from "./contexts"; import { renderMessageToStdout } from "./components/MessageView/utils"; @@ -76,6 +78,12 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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); @@ -105,6 +113,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setStatusLine(buildStatusLine(entry)); setRunningProcesses(entry.processes); setActiveStatus(entry.status); + setActiveAskPermissions(entry.askPermissions); }, onLlmStreamProgress: (progress) => { if (progress.phase === "end") { @@ -214,6 +223,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setErrorLine(null); setRunningProcesses(null); setActiveStatus(null); + setActiveAskPermissions(undefined); + setPendingPermissionReply(null); setDismissedQuestionIds(new Set()); setShowWelcome(true); setWelcomeNonce((n) => n + 1); @@ -257,7 +268,16 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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) ?? []; @@ -277,6 +297,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. processStdoutRef.current.clear(); try { await sessionManager.handleUserPrompt(prompt); + if (permissionReply) { + setPendingPermissionReply(null); + } await refreshSkills(); refreshSessionsList(); } catch (error) { @@ -288,7 +311,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setRunningProcesses(null); } }, - [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] + [exit, onRestart, pendingPermissionReply, sessionManager, refreshSkills, refreshSessionsList] ); const handleInterrupt = useCallback(() => { @@ -407,9 +430,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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] + [pendingPermissionReply, sessionManager, refreshSkills] ); const handleUndoRestore = useCallback( @@ -605,6 +632,39 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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."); + 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); + refreshSessionsList(); + }, [refreshSessionsList, sessionManager]); + if (mode === RawMode.Raw) { return handleRawModeChange(prev)} />; } @@ -683,6 +743,16 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. onSubmit={handleQuestionAnswers} onCancel={handleQuestionCancel} /> + ) : activeStatus === "ask_permission" && + activeAskPermissions && + activeAskPermissions.length > 0 && + !pendingPermissionReply && + !busy ? ( + ) : isExiting ? null : ( void; + onCancel: () => void; +}; + +type ScopePrompt = { + request: AskPermissionRequest; + scope: AskPermissionScope; +}; + +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}. {option.label} + + ))} + + + ↑/↓ move · Enter select · Esc interrupt + + + ); +} + +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): Array<{ kind: "allow" | "always" | "deny"; label: string }> { + const options: Array<{ kind: "allow" | "always" | "deny"; label: string }> = [{ kind: "allow", label: "Yes" }]; + if (isAlwaysAllowedScope(scope)) { + options.push({ kind: "always", label: `Yes, and always allow ${describeScope(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); +} + +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/PromptInput.tsx b/src/ui/PromptInput.tsx index 8897fd3..8c808e9 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -46,7 +46,7 @@ import { } from "./fileMentions"; import type { FileMentionItem } from "./fileMentions"; import { readClipboardImageAsync } from "./clipboard"; -import type { SessionEntry, SkillInfo } from "../session"; +import type { PermissionScope, SessionEntry, SkillInfo, UserToolPermission } from "../session"; // Re-exported from prompt modules for backward compatibility export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt"; @@ -68,6 +68,8 @@ export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; }; diff --git a/templates/tools/bash.md b/templates/tools/bash.md index 0705120..e8597ab 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. `unknown` must appear alone. - 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 } From 90c6b2e7ea6c6c1e343c074c06f00c5c09391d68 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 22 May 2026 21:42:32 +0800 Subject: [PATCH 07/21] chore: update bash.md --- templates/tools/bash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/tools/bash.md b/templates/tools/bash.md index e8597ab..83027d3 100644 --- a/templates/tools/bash.md +++ b/templates/tools/bash.md @@ -32,7 +32,7 @@ Usage notes: - 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. `unknown` must appear alone. + - 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: From 104acff28f6fdc72ee7731813c3aa6069eb73235 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Sat, 23 May 2026 00:02:13 +0800 Subject: [PATCH 08/21] feat: enhance appendProjectPermissionAllows to support inherited permissions --- .gitignore | 1 + src/common/permissions.ts | 35 ++++++++-- src/session.ts | 4 +- src/tests/permissions.test.ts | 120 ++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) 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/src/common/permissions.ts b/src/common/permissions.ts index e9aae01..aa87e0d 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -360,7 +360,11 @@ export function hasUserPermissionReplies(value: { permissions?: unknown; alwaysA ); } -export function appendProjectPermissionAllows(projectRoot: string, scopes: PermissionScope[] | undefined): void { +export function appendProjectPermissionAllows( + projectRoot: string, + scopes: PermissionScope[] | undefined, + options: { inheritedPermissions?: Required } = {} +): void { if (!Array.isArray(scopes) || scopes.length === 0) { return; } @@ -392,14 +396,35 @@ export function appendProjectPermissionAllows(projectRoot: string, scopes: Permi } catch { settings = {}; } - const currentAllow = Array.isArray(settings.permissions?.allow) ? settings.permissions.allow : []; + + 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); } } - if (allow.length === currentAllow.length) { + 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 }); @@ -409,7 +434,9 @@ export function appendProjectPermissionAllows(projectRoot: string, scopes: Permi { ...settings, permissions: { - ...(settings.permissions ?? {}), + ...permissions, + deny, + ask, allow, }, }, diff --git a/src/session.ts b/src/session.ts index c5da055..a8a194e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1045,7 +1045,9 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); - appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows, { + inheritedPermissions: this.getResolvedSettings().permissions, + }); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, diff --git a/src/tests/permissions.test.ts b/src/tests/permissions.test.ts index adb5388..8babf11 100644 --- a/src/tests/permissions.test.ts +++ b/src/tests/permissions.test.ts @@ -106,6 +106,126 @@ test("appendProjectPermissionAllows writes unique project-level allow scopes", ( 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); From bacb6a4fab37a38be434485eeea194dd36f6b4bc Mon Sep 17 00:00:00 2001 From: xinggitxing Date: Sat, 23 May 2026 18:32:52 +0800 Subject: [PATCH 09/21] feat(ui): add session deletion with Delete key confirmation - Add SessionManager.deleteSession() to remove session index entry and messages file - Add Delete key to trigger session deletion confirmation in SessionList - Two-step confirmation: Enter to confirm, Esc to cancel - Separate backspace (search) and delete (delete trigger) key behavior - Clear active session if deleted session was the active one - Add comprehensive test coverage for deleteSession --- src/session.ts | 22 +++++++ src/tests/session.test.ts | 129 ++++++++++++++++++++++++++++++++++++++ src/ui/App.tsx | 8 +++ src/ui/SessionList.tsx | 71 +++++++++++++++++---- 4 files changed, 218 insertions(+), 12 deletions(-) diff --git a/src/session.ts b/src/session.ts index 54340e7..a3a6dd1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1476,6 +1476,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)) { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index fd83199..a8d943f 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -2123,6 +2123,135 @@ test("SessionManager adjusts the active Bash timeout control and session metadat 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" }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5419a2a..942bbf8 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -658,6 +658,14 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. sessions={sessions} onSelect={(id) => void handleSelectSession(id)} onCancel={() => setView("chat")} + onDelete={(id) => { + // If the deleted session is the active one, clear it + if (sessionManager.getActiveSessionId() === id) { + sessionManager.setActiveSessionId(null); + } + sessionManager.deleteSession(id); + refreshSessionsList(); + }} /> ) : view === "undo" ? ( void; onCancel: () => void; + onDelete?: (sessionId: string) => void; }; /** @@ -36,9 +37,10 @@ export function filterSessions(sessions: SessionEntry[], query: string): Session }); } -export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { +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 @@ -77,7 +79,23 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac setIndex(0); }, []); + const selectedSession = filteredSessions[safeIndex]; + useInput((input, key) => { + // 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) { @@ -95,13 +113,25 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac return; } - // Backspace / Delete: remove last search character - if (key.backspace || key.delete) { + // Backspace: remove last search character + if (key.backspace) { + if (searchQuery) { + handleBackspace(); + return; + } + } + + // Delete key: remove search character, or start delete confirmation + if (key.delete) { if (searchQuery) { handleBackspace(); return; } - // If no search query, navigation keys below handle the rest + // No search query: start delete confirmation if session is selected + if (selectedSession && onDelete) { + setConfirmDeleteSessionId(selectedSession.id); + return; + } } // Printable character: append to search query @@ -211,20 +241,23 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac ) : ( visibleSessions.map((session, i) => { const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + const isConfirming = confirmDeleteSessionId === session.id; return ( - {actualIndex === safeIndex ? "> " : " "} + {isSelected ? "> " : " "} - + {formatSessionTitle(session.summary || "Untitled")} - ({formatSessionStatus(session.status)}) + {isConfirming ? ( + [Delete? Enter=yes, Esc=no] + ) : ( + ({formatSessionStatus(session.status)}) + )} {formatTimestamp(session.updateTime)} @@ -245,14 +278,28 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac {/* Footer */} - {hasActiveSearch ? ( + {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 + + Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel · Del delete + )} From 928551e127b0df5f77b44aaee030863f838406e0 Mon Sep 17 00:00:00 2001 From: dengm Date: Sat, 23 May 2026 19:58:33 +0800 Subject: [PATCH 10/21] feat: add closed-border markdown table rendering with CJK/emoji support - Detect markdown tables and render with Unicode box-drawing characters - Calculate visual terminal width for CJK/emoji (2 cols) vs ASCII (1 col) - Wrap long cells across multiple lines, prefer word-boundary breaks - Allocate column widths: narrow columns (#, status, count, date) minimal, content columns kept >= 12 chars - Render tables with to prevent Ink from breaking box-drawing lines at cell boundary spaces - Expose renderMarkdownSegments() for per-segment wrapping control --- Screenshot_2026-05-23_195028.png | Bin 0 -> 105561 bytes src/ui/components/MessageView/index.tsx | 17 +- src/ui/components/MessageView/markdown.ts | 324 +++++++++++++++++++--- src/ui/index.ts | 2 +- 4 files changed, 304 insertions(+), 39 deletions(-) create mode 100644 Screenshot_2026-05-23_195028.png diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png new file mode 100644 index 0000000000000000000000000000000000000000..870fbaae9e6cb8f3940673faac16d3811fea2f41 GIT binary patch literal 105561 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 - - {content ? {renderMarkdown(content)} : null} + + {content + ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { + if (seg.kind === "table") { + return ( + + {seg.body} + + ); + } + return {seg.body}; + }) + : null} ); diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 11fb0ea..8c86534 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -1,22 +1,61 @@ import chalk from "chalk"; -export function renderMarkdown(text: string): string { - if (!text) { - return ""; - } +/** + * 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) + .join(""); +} + +/** 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); - return fenceSegments - .map((segment) => { - if (segment.kind === "code") { - const langTag = segment.lang ? chalk.dim(`[${segment.lang}]`) + "\n" : ""; - return langTag + chalk.cyan(segment.body); + + 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 renderInlineBlock(segment.body); - }) - .join(""); + } + } + + return segments; } +// --------------------------------------------------------------------------- +// Code fences +// --------------------------------------------------------------------------- + type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; function splitByFences(text: string): FenceSegment[] { @@ -28,35 +67,27 @@ function splitByFences(text: string): FenceSegment[] { let fenceBody: string[] = []; const flushText = () => { - if (buffer.length === 0) { - return; + if (buffer.length > 0) { + segments.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; } - segments.push({ kind: "text", body: buffer.join("\n") }); - buffer = []; }; for (const line of lines) { - const fenceMatch = /^\s*```(\w*)\s*$/.exec(line); - if (fenceMatch) { + const m = /^\s*```(\w*)\s*$/.exec(line); + if (m) { if (!inFence) { flushText(); inFence = true; - fenceLang = fenceMatch[1] ?? ""; + fenceLang = m[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); - } + (inFence ? fenceBody : buffer).push(line); } if (inFence) { @@ -68,13 +99,238 @@ function splitByFences(text: string): FenceSegment[] { return segments; } -function renderInlineBlock(text: string): string { - return text - .split("\n") - .map((line) => renderInlineLine(line)) - .join("\n"); +// --------------------------------------------------------------------------- +// 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*$/; + + 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 = [ + trimmed + .split("|") + .filter(Boolean) + .map((s) => s.trim()), + ]; + continue; + } + + if (isRow && inTable) { + tableRows.push( + trimmed + .split("|") + .filter(Boolean) + .map((s) => s.trim()) + ); + 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 calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; + + // Ideal widths — longest word / 1.5 so cells can wrap in 2-3 lines + const ideal: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = rows.map((r) => r[i] ?? ""); + const maxLine = Math.max(...texts.map((t) => visualWidth(t))); + const words = texts.flatMap((t) => t.split(/\s+/)); + const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); + return Math.max(maxWord + 2, Math.ceil(maxLine / 1.5)); + }); + + const colWidths = [...ideal]; + + // Shrink to fit terminal width + if (maxWidth != null && calcW(colWidths) > maxWidth) { + const narrow = new Set([0, 1, colCount - 2, colCount - 1]); // #, status, count, date + const MIN_NARROW = 6; + const MIN_CONTENT = 12; + const contentCols = Array.from({ length: colCount }, (_, i) => i).filter((i) => !narrow.has(i)); + + // Cap narrow columns first + for (const ci of narrow) colWidths[ci] = Math.min(colWidths[ci], MIN_NARROW); + + // Shrink until we fit + while (calcW(colWidths) > maxWidth) { + // Try narrow columns first + let shrunk = false; + for (const ci of narrow) { + if (colWidths[ci] > 4 && calcW(colWidths) > maxWidth) { + colWidths[ci]--; + shrunk = true; + } + } + if (shrunk) continue; + // Then content columns + const widest = contentCols.reduce((a, b) => (colWidths[a] > colWidths[b] ? a : b), contentCols[0]); + if (colWidths[widest] > MIN_CONTENT) colWidths[widest]--; + else break; + } + } + + // 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 = rows.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) { @@ -105,9 +361,7 @@ function renderInlineLine(line: string): string { } function renderInlineSpans(text: string): string { - if (!text) { - return text; - } + if (!text) return text; let result = text; result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); diff --git a/src/ui/index.ts b/src/ui/index.ts index d899d4b..1348903 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -54,7 +54,7 @@ export { } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./components/MessageView/markdown"; +export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; export { EMPTY_BUFFER, insertText, From 809670952601f7dedf738008190b9d8d84054491 Mon Sep 17 00:00:00 2001 From: dengmik-commits Date: Sat, 23 May 2026 20:05:13 +0800 Subject: [PATCH 11/21] add screenshot --- Screenshot_2026-05-23_195028.png | Bin 105561 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png index 870fbaae9e6cb8f3940673faac16d3811fea2f41..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 105561 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 Date: Mon, 25 May 2026 08:46:26 +0800 Subject: [PATCH 12/21] feat: add getScopeRiskColor function for permission scope risk mapping --- src/common/permissions.ts | 35 ++++++++++++++++++- src/tests/permission-prompt.test.ts | 19 +++++++++++ src/tests/permissions.test.ts | 33 ++++++++++++++++++ src/ui/PermissionPrompt.tsx | 53 ++++++++++++++++++++++++++--- 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 src/tests/permission-prompt.test.ts diff --git a/src/common/permissions.ts b/src/common/permissions.ts index aa87e0d..564bfeb 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -164,9 +164,10 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp 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: request.scopes, + scopes: askScopes.length > 0 ? askScopes : request.scopes, name: request.name, command: request.command, description: request.description, @@ -285,6 +286,38 @@ export function evaluatePermissionScopes( 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", 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 index 8babf11..3a28616 100644 --- a/src/tests/permissions.test.ts +++ b/src/tests/permissions.test.ts @@ -93,6 +93,39 @@ test("computeToolCallPermissions maps tool calls to permission requests", () => ); }); +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"); diff --git a/src/ui/PermissionPrompt.tsx b/src/ui/PermissionPrompt.tsx index 4613639..03881a5 100644 --- a/src/ui/PermissionPrompt.tsx +++ b/src/ui/PermissionPrompt.tsx @@ -20,6 +20,13 @@ type ScopePrompt = { 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", @@ -138,7 +145,7 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React {options.map((option, optionIndex) => ( {optionIndex === cursor ? "> " : " "} - {optionIndex + 1}. {option.label} + {optionIndex + 1}. {renderOptionLabel(option)} ))} @@ -149,6 +156,18 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React ); } +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) { @@ -159,10 +178,15 @@ function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { return prompts; } -function buildOptions(scope: AskPermissionScope): Array<{ kind: "allow" | "always" | "deny"; label: string }> { - const options: Array<{ kind: "allow" | "always" | "deny"; label: string }> = [{ kind: "allow", label: "Yes" }]; +function buildOptions(scope: AskPermissionScope): PromptOption[] { + const options: PromptOption[] = [{ kind: "allow", label: "Yes" }]; if (isAlwaysAllowedScope(scope)) { - options.push({ kind: "always", label: `Yes, and always allow ${describeScope(scope)}` }); + options.push({ + kind: "always", + label: "Yes, and always allow ", + scopeDescription: describeScope(scope), + scopeColor: getScopeRiskColor(scope), + }); } options.push({ kind: "deny", label: "No" }); return options; @@ -201,6 +225,27 @@ function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionSco 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": From 5d6f727eb686ebdf662e715fe8513c5df05adfbb Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 11:15:09 +0800 Subject: [PATCH 13/21] feat: add permission.md and update README.md --- README-en.md | 4 ++ README-zh_CN.md | 5 +++ README.md | 5 +++ docs/permission.md | 101 ++++++++++++++++++++++++++++++++++++++++++ docs/permission_en.md | 100 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 docs/permission.md create mode 100644 docs/permission_en.md diff --git a/README-en.md b/README-en.md index 4bff6af..c1d4acb 100644 --- a/README-en.md +++ b/README-en.md @@ -137,6 +137,10 @@ When the AI assistant completes a task, Deep Code can automatically execute a no 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: diff --git a/README-zh_CN.md b/README-zh_CN.md index 77db497..2643756 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -122,6 +122,10 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[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 为例: @@ -136,6 +140,7 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 "thinkingEnabled": true } ``` + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/README.md b/README.md index 77db497..2643756 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[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 为例: @@ -136,6 +140,7 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 "thinkingEnabled": true } ``` + ## 贡献 欢迎贡献代码!以下是参与方式: 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. From 7c95312f68623c9da6ce1c865f2dd996d16b2cf5 Mon Sep 17 00:00:00 2001 From: dengm Date: Mon, 25 May 2026 11:17:12 +0800 Subject: [PATCH 14/21] =?UTF-8?q?fix:=20improve=20table=20column=20width?= =?UTF-8?q?=20allocation=20=E2=80=94=20use=20natural=20widths=20and=20grow?= =?UTF-8?q?=20to=20fill=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the aggressive maxLine/1.5 ideal-width heuristic with full natural widths. When the total fits within the available terminal width (defaulting to 120 cols), distribute slack proportionally to content columns instead of leaving them cramped. Detect narrow label columns by actual content width (≤8 chars) rather than hardcoded position ([0, 1, -2, -1]). When compression is necessary, start from per-column minimums (longest word) and share the remaining budget proportionally based on each column's deficit. This fixes the "tables too narrow and too tall" issue reported on PR #115 where every column was forced to ~67% of its natural width regardless of available screen real estate. --- src/ui/components/MessageView/markdown.ts | 84 +++++++++++++++-------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 8c86534..f5b72bc 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -231,42 +231,68 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { const colCount = rows[0].length; const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; - // Ideal widths — longest word / 1.5 so cells can wrap in 2-3 lines - const ideal: number[] = Array.from({ length: colCount }, (_, i) => { + // Natural width per column: longest line + cell padding + const natural: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = rows.map((r) => r[i] ?? ""); + const maxLine = Math.max(4, ...texts.map((t) => visualWidth(t))); + return maxLine + 2; + }); + + // Minimum width per column: longest word + padding (can't go below this) + const minWidths: number[] = Array.from({ length: colCount }, (_, i) => { const texts = rows.map((r) => r[i] ?? ""); - const maxLine = Math.max(...texts.map((t) => visualWidth(t))); const words = texts.flatMap((t) => t.split(/\s+/)); const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); - return Math.max(maxWord + 2, Math.ceil(maxLine / 1.5)); + return maxWord + 2; }); - const colWidths = [...ideal]; - - // Shrink to fit terminal width - if (maxWidth != null && calcW(colWidths) > maxWidth) { - const narrow = new Set([0, 1, colCount - 2, colCount - 1]); // #, status, count, date - const MIN_NARROW = 6; - const MIN_CONTENT = 12; - const contentCols = Array.from({ length: colCount }, (_, i) => i).filter((i) => !narrow.has(i)); - - // Cap narrow columns first - for (const ci of narrow) colWidths[ci] = Math.min(colWidths[ci], MIN_NARROW); - - // Shrink until we fit - while (calcW(colWidths) > maxWidth) { - // Try narrow columns first - let shrunk = false; - for (const ci of narrow) { - if (colWidths[ci] > 4 && calcW(colWidths) > maxWidth) { - colWidths[ci]--; - shrunk = true; + 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); + } } } - if (shrunk) continue; - // Then content columns - const widest = contentCols.reduce((a, b) => (colWidths[a] > colWidths[b] ? a : b), contentCols[0]); - if (colWidths[widest] > MIN_CONTENT) colWidths[widest]--; - else break; + } + } else if (totalMin >= effectiveMax) { + // Even minimums don't fit — use mins and accept truncation + colWidths = [...minWidths]; + } 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); + } } } From abed1495821b1c9d82f870d05e62dd83afffbaa6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 25 May 2026 11:47:09 +0800 Subject: [PATCH 15/21] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=8A=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=A0=E9=99=A4=E5=8F=8A=E7=9B=B8=E5=85=B3UI?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 handleDeleteSession 方法,支持删除会话并更新会话列表 - 删除当前激活会话时,清除屏幕、重置UI状态并显示欢迎界面 - 删除按钮调用封装的删除处理函数,统一逻辑 - 优化 SessionList 中搜索逻辑,调整删除和退格键处理 - 移除 SessionList 文件内重复的 truncate 函数实现 --- src/ui/App.tsx | 181 ++++++++++++++++++++++------------------ src/ui/AppContainer.tsx | 2 +- src/ui/SessionList.tsx | 19 +---- src/ui/constants.ts | 3 + src/ui/utils/index.ts | 24 ++++++ 5 files changed, 132 insertions(+), 97 deletions(-) create mode 100644 src/ui/utils/index.ts diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3879915..71e9ca3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -43,6 +43,8 @@ import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPromp 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"; @@ -55,7 +57,7 @@ type AppProps = { onRestart?: () => void; }; -export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); @@ -142,6 +144,33 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. }); }, [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; @@ -170,6 +199,26 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. [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(); @@ -182,11 +231,17 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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(); @@ -216,33 +271,19 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. if (onRestart) { onRestart(); } else { - writeRef.current("\u001B[2J\u001B[3J\u001B[H"); - sessionManager.setActiveSessionId(null); - setMessages([]); - setStatusLine(""); - setErrorLine(null); - setRunningProcesses(null); - setActiveStatus(null); - setActiveAskPermissions(undefined); - setPendingPermissionReply(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)) { - setShowWelcome(false); refreshSessionsList(); - setView("session-list"); + navigateToSubView("session-list"); return; } if (submission.command === "undo") { @@ -251,15 +292,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setErrorLine("No active session to undo."); return; } - setShowWelcome(false); setUndoTargets(sessionManager.listUndoTargets(activeSessionId)); - setView("undo"); + navigateToSubView("undo"); return; } if (submission.command === "mcp") { - setShowWelcome(false); setMcpStatuses(sessionManager.getMcpStatus()); - setView("mcp-status"); + navigateToSubView("mcp-status"); return; } @@ -311,7 +350,16 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setRunningProcesses(null); } }, - [exit, onRestart, pendingPermissionReply, sessionManager, refreshSkills, refreshSessionsList] + [ + sessionManager, + pendingPermissionReply, + exit, + onRestart, + refreshSkills, + refreshSessionsList, + navigateToSubView, + resetToWelcome, + ] ); const handleInterrupt = useCallback(() => { @@ -384,16 +432,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. const reloadActiveSessionView = useCallback( (sessionId: string): void => { - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - setTimeout(() => { - setMessages(loadVisibleMessages(sessionManager, sessionId)); - setShowWelcome(true); - }, 0); + resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); }, - [sessionManager] + [resetStaticView, sessionManager] ); useEffect(() => { @@ -411,21 +452,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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); @@ -436,7 +465,26 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. } await refreshSkills(sessionId); }, - [pendingPermissionReply, 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( @@ -487,25 +535,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. setShowWelcome(false); setMessages([]); // Clear screen to remove stale formatted text. - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + 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) : []; - for (const msg of allMessages) { - process.stdout.write("\n"); - process.stdout.write(renderMessageToStdout(msg, nextMode) + "\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")); - } + renderRawModeMessages(allMessages, nextMode); } else if (activeSessionId) { // Switch to chat view to render messages. handleSelectSession(activeSessionId); @@ -538,22 +574,10 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. 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("\u001B[2J\u001B[3J\u001B[H"); + process.stdout.write(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; - for (const msg of allMessages) { - process.stdout.write("\n"); - process.stdout.write(renderMessageToStdout(msg, mode) + "\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")); - } + renderRawModeMessages(allMessages, mode); return; } @@ -719,12 +743,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. onSelect={(id) => void handleSelectSession(id)} onCancel={() => setView("chat")} onDelete={(id) => { - // If the deleted session is the active one, clear it - if (sessionManager.getActiveSessionId() === id) { - sessionManager.setActiveSessionId(null); - } - sessionManager.deleteSession(id); - refreshSessionsList(); + void handleDeleteSession(id); }} /> ) : view === "undo" ? ( @@ -784,6 +803,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React. ); } +export default App; + function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { if (message.role !== "assistant") { return false; diff --git a/src/ui/AppContainer.tsx b/src/ui/AppContainer.tsx index e437b44..c8b3177 100644 --- a/src/ui/AppContainer.tsx +++ b/src/ui/AppContainer.tsx @@ -1,6 +1,6 @@ import React from "react"; import { AppContext } from "./contexts"; -import { App } from "./App"; +import App from "./App"; import { RawModeProvider } from "./contexts/RawModeContext"; const AppContainer: React.FC<{ diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 7d7e04e..82ca797 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,7 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { SessionEntry, SessionStatus } from "../session"; +import { truncate } from "./components/MessageView/utils"; type Props = { sessions: SessionEntry[]; @@ -113,17 +114,10 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): return; } - // Backspace: remove last search character - if (key.backspace) { - if (searchQuery) { - handleBackspace(); - return; - } - } - // Delete key: remove search character, or start delete confirmation - if (key.delete) { + if (key.delete || key.backspace) { if (searchQuery) { + // remove last search character handleBackspace(); return; } @@ -342,10 +336,3 @@ export function formatSessionStatus(status: SessionStatus): string { return status; } } - -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; - } - return `${value.slice(0, max)}…`; -} diff --git a/src/ui/constants.ts b/src/ui/constants.ts index 7c74597..43372f8 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -2,3 +2,6 @@ /** 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/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")); + } +} From 679eb003515d15a9bf2c6f3d147650cd5d768fb9 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 14:45:39 +0800 Subject: [PATCH 16/21] feat: enhance markdown table rendering with CJK support and improved width handling --- src/tests/markdown.test.ts | 55 ++++++++++++++++++++++- src/ui/components/MessageView/index.tsx | 10 +++-- src/ui/components/MessageView/markdown.ts | 54 ++++++++++++---------- 3 files changed, 92 insertions(+), 27 deletions(-) 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/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 093dbc2..9c31551 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -71,9 +71,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { if (seg.kind === "table") { return ( - - {seg.body} - + + {seg.body.split("\n").map((line, lineIndex) => ( + + {line} + + ))} + ); } return {seg.body}; diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index f5b72bc..3ebb58b 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -18,7 +18,11 @@ export type MarkdownSegment = export function renderMarkdown(text: string, maxWidth?: number): string { return renderMarkdownSegments(text, maxWidth) .map((s) => s.body) - .join(""); + .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 @@ -128,6 +132,12 @@ function splitTableBlocks(text: string): TableBlock[] { }; 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]; @@ -143,22 +153,12 @@ function splitTableBlocks(text: string): TableBlock[] { if (isHeader && !inTable) { flushText(); inTable = true; - tableRows = [ - trimmed - .split("|") - .filter(Boolean) - .map((s) => s.trim()), - ]; + tableRows = [parseRow(trimmed)]; continue; } if (isRow && inTable) { - tableRows.push( - trimmed - .split("|") - .filter(Boolean) - .map((s) => s.trim()) - ); + tableRows.push(parseRow(trimmed)); continue; } @@ -229,21 +229,26 @@ 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: longest line + cell padding + // Natural width per column, measured as terminal cells rather than UTF-16 units. const natural: number[] = Array.from({ length: colCount }, (_, i) => { - const texts = rows.map((r) => r[i] ?? ""); + const texts = normalizedRows.map((r) => r[i] ?? ""); const maxLine = Math.max(4, ...texts.map((t) => visualWidth(t))); - return maxLine + 2; + return maxLine; }); - // Minimum width per column: longest word + padding (can't go below this) + // Keep minimums small so long CJK text or unbroken tokens can wrap by character. const minWidths: number[] = Array.from({ length: colCount }, (_, i) => { - const texts = rows.map((r) => r[i] ?? ""); - const words = texts.flatMap((t) => t.split(/\s+/)); - const maxWord = Math.max(4, ...words.map((w) => visualWidth(w))); - return maxWord + 2; + 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[]; @@ -270,8 +275,11 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { } } } else if (totalMin >= effectiveMax) { - // Even minimums don't fit — use mins and accept truncation 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; @@ -327,7 +335,7 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { return lines.length > 0 ? lines : [""]; }; - const wrapped = rows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); + 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))); From 670b118cd64c28a01c0a1ae985279e0807300e2d Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 25 May 2026 15:10:37 +0800 Subject: [PATCH 17/21] =?UTF-8?q?fix(session):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8B=92=E7=BB=9D=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 permission_denied 会话状态表示权限被拒绝 - 添加 denySessionPermission 方法以更新会话状态为拒绝并设置失败原因 - 在权限拒绝时清除提示草稿并调用拒绝权限处理逻辑 - 中断会话时清除提示草稿以防止残留输入 - 会话列表中新增 permission_denied 状态对应的 UI 状态映射为 denied --- src/session.ts | 17 ++++++++++++++++- src/ui/App.tsx | 3 +++ src/ui/SessionList.tsx | 4 ++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index c81ae4a..4b34f20 100644 --- a/src/session.ts +++ b/src/session.ts @@ -159,7 +159,8 @@ export type SessionStatus = | "waiting_for_user" | "completed" | "interrupted" - | "ask_permission"; + | "ask_permission" + | "permission_denied"; export type ModelUsage = { prompt_tokens: number; @@ -1532,6 +1533,20 @@ ${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)) { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 71e9ca3..ae94fa0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -669,6 +669,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl alwaysAllows: result.alwaysAllows, }); setStatusLine("Permission denied. Add a reply, then press Enter to continue."); + setPromptDraft(null); + sessionManager.denySessionPermission(sessionId); return; } void handlePrompt({ @@ -686,6 +688,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl sessionManager.interruptActiveSession(); setActiveStatus("interrupted"); setActiveAskPermissions(undefined); + setPromptDraft(null); refreshSessionsList(); }, [refreshSessionsList, sessionManager]); diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 82ca797..2d83b84 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -332,6 +332,10 @@ export function formatSessionStatus(status: SessionStatus): string { return "failed"; case "interrupted": return "stopped"; + case "ask_permission": + return "waiting"; + case "permission_denied": + return "denied"; default: return status; } From f1ecc26f4cf9f29c820138d005cfa604a76ffce4 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:03:22 +0800 Subject: [PATCH 18/21] feat: update the MCP spawn to avoid DEP0190 problem --- src/mcp/mcp-client.ts | 61 +++++++++++++++++++++++++----------- src/tests/mcp-client.test.ts | 34 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 src/tests/mcp-client.test.ts diff --git a/src/mcp/mcp-client.ts b/src/mcp/mcp-client.ts index b859bf5..26a7a32 100644 --- a/src/mcp/mcp-client.ts +++ b/src/mcp/mcp-client.ts @@ -1,6 +1,5 @@ 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"; @@ -97,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; @@ -130,25 +136,14 @@ export class McpClient { ...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) => { @@ -421,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/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, []); +}); From a1b31c635263d22c486559f2c029242d51e35462 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:15:14 +0800 Subject: [PATCH 19/21] feat(session): Add support for permission_denied status --- src/session.ts | 3 ++- src/tests/session.test.ts | 51 +++++++++++++++++++++++++++++++++++ src/tests/sessionList.test.ts | 2 ++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 4b34f20..a9fc39e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2730,7 +2730,8 @@ ${skillMd} status === "waiting_for_user" || status === "completed" || status === "interrupted" || - status === "ask_permission" + status === "ask_permission" || + status === "permission_denied" ) { return status; } diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index e0a863e..95de8e3 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -1313,6 +1313,57 @@ test("activateSession pauses for permission when a tool call requires ask", asyn ); }); +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-"); diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts index 3dfda33..6fe41c7 100644 --- a/src/tests/sessionList.test.ts +++ b/src/tests/sessionList.test.ts @@ -18,6 +18,8 @@ test("formatSessionStatus maps status values to display labels", () => { 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"); }); From 09ae2b43f02a5dbb0c1e36e0825d8910b17061b0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:29:49 +0800 Subject: [PATCH 20/21] chore: clean up non-project files --- Screenshot_2026-05-23_195028.png | 0 docs/issue_0522.md | 241 ------------------------------- 2 files changed, 241 deletions(-) delete mode 100644 Screenshot_2026-05-23_195028.png delete mode 100644 docs/issue_0522.md diff --git a/Screenshot_2026-05-23_195028.png b/Screenshot_2026-05-23_195028.png deleted file mode 100644 index e69de29..0000000 diff --git a/docs/issue_0522.md b/docs/issue_0522.md deleted file mode 100644 index 2e9fd1a..0000000 --- a/docs/issue_0522.md +++ /dev/null @@ -1,241 +0,0 @@ -# Deep Code Permission System (设计文档) - -scopes是枚举值,列表如下: - -``` -# 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 -``` - -settings.json的配置项(例子): - -``` -{ - "permissions": { - "allow": [ - "write-in-cwd" - ], - "deny": [ - "write-out-cwd" - ], - "ask": [ - "read-out-cwd" - ], - "defaultMode": "allowAll|askAll" // 默认是allowAll - } -} -``` - -工具和PermissionScope可能的对应关系: - -- read: read-in-cwd, read-out-cwd -- write: write-in-cwd, write-out-cwd -- edit: write-in-cwd, write-out-cwd -- WebSearch: network -- mcp__*: mcp -- bash: 每一次bash命令需要的scope在sideEffects字段中。如果sideEffects字段为undefined|null,或者sideEffects包含了"unknown"则总是ask -- 其他: 无权限要求,总是允许 - -## bash tool的参数schema新增sideEffects字段 - -目标:让LLM在每一次调用`bash`时显式声明该命令可能需要的权限范围,后端只信任这个结构化字段,不从自然语言`description`中推断权限。 - -需要同步修改两处schema: - -1. `src/prompt.ts`里的`getTools()`内置`bash`工具定义。 -2. `templates/tools/bash.md`里的`bash`工具说明和JSON schema示例。 - -新增字段: - -``` -sideEffects: PermissionScope[] | ["unknown"] -``` - -`bash`可声明的scope只包含文件系统、Git历史和网络权限,不包含`mcp`: - -``` -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 -``` - -建议schema如下: - -```json -{ - "type": "object", - "properties": { - "command": { - "description": "The command to execute", - "type": "string" - }, - "description": { - "description": "Clear, concise description of what this command does in active voice.", - "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", - "sideEffects" - ], - "additionalProperties": false -} -``` - -字段语义: - -- `sideEffects: []`表示命令不需要权限,例如`date`、`node --version`这类只读取进程环境或输出版本信息的命令。 -- `sideEffects`必须按最小必要权限填写;例如`rg foo src`是`["read-in-cwd"]`,`npm install`通常是`["write-in-cwd", "network"]`。 -- 如果命令访问项目目录之外的路径,需要使用`*-out-cwd`;例如`cat /etc/hosts`是`["read-out-cwd"]`。 -- 删除类操作使用`delete-*`;如果同一条命令还会写入其他文件,再同时声明对应的`write-*`。 -- 查询Git历史使用`query-git-log`;例如`git log`、`git show HEAD`、`git blame`、`git diff HEAD~1..HEAD`这类读取提交历史、提交对象或历史diff的命令。 -- 修改Git历史或引用使用`mutate-git-log`;例如`git commit`、`git reset`、`git rebase`、`git merge`、`git cherry-pick`、`git tag`这类会创建提交、移动引用或改写提交图的命令。 -- Git命令如果同时读写工作区文件,也需要同时声明文件系统scope;例如`git checkout -- src/foo.ts`需要`["write-in-cwd"]`,`git reset --hard HEAD~1`需要`["write-in-cwd", "mutate-git-log"]`。 -- `unknown`只能单独出现为`["unknown"]`,不能和其他scope混用。 - -示例: - -```json -{ "command": "date", "description": "Show current date", "sideEffects": [] } -{ "command": "rg \"TODO\" src", "description": "Search TODO markers in source files", "sideEffects": ["read-in-cwd"] } -{ "command": "npm install", "description": "Install package dependencies", "sideEffects": ["write-in-cwd", "network"] } -{ "command": "rm -rf dist", "description": "Delete build output directory", "sideEffects": ["delete-in-cwd"] } -{ "command": "curl -s https://example.com", "description": "Fetch example.com response", "sideEffects": ["network"] } -{ "command": "git show --stat HEAD", "description": "Show file statistics for HEAD", "sideEffects": ["query-git-log"] } -{ "command": "git blame src/prompt.ts", "description": "Show line authorship for prompt source", "sideEffects": ["read-in-cwd", "query-git-log"] } -{ "command": "git reset --hard HEAD~1", "description": "Reset branch and worktree to previous commit", "sideEffects": ["write-in-cwd", "mutate-git-log"] } -``` - -## 核心数据结构设计 - -``` -export type UserPromptContent = { - text?: string; - imageUrls?: string[]; - skills?: SkillInfo[]; -+ permissions?: [{toolCallId: "...", permission: "allow|deny"}]; -+ alwaysAllows?: [""]; -}; - -export type SessionEntry = { - id: string; - ... - toolCalls: unknown[] | null; // 例如:[{"id":"...","function":{"name":"bash","arguments":"{\"command\": \"...\", \"description\": \"...\"}"}}] - status: SessionStatus; -+ askPermissions?: [{toolCallId: "...", scopes: [""], name: "...", command: "...", description?: "..."}]; -}; - -export type SessionStatus = "... | "completed" | "interrupted" | "ask_permission"; // 新增 ask_permission 状态 - -export type SessionMessage = { - ... - meta?: MessageMeta; - ... -}; - -export type MessageMeta = { - ... -+ permissions?: [{toolCallId: "...", permission: "allow|deny|ask"}]; -+ userPrompt?: UserPromptContent; //对于role为user的消息,持久化userPrompt可方便后续排查问题 -}; -``` - -## 前端流程 - -如果当前会话状态不是ask_permission,则保持现状。会话状态是ask_permission时: - -对SessionEntry.askPermissions中每一个toolCallId的每一个scope,显示权限弹窗(示例): - -``` - - - - - - Do you want to proceed? - ❯ 1. Yes - 2. Yes, and always allow - 3. No -``` - -注意对于read/write/edit的``,格式可以是"工具名称+相对或绝对文件路径",例如:`read ~/dev/main.c` - -如果在权限弹窗过程中,用户按Esc,则走现有的interrupt流程(会话状态也应该变成"interrupted")。 - -提醒注意一种情况:例如askPermissions里面有好几个item的scopes是`["write-in-cwd"]`,如果用户已经在第一个权限弹窗选了"always allow write in CWD `~/dev/qrcode_test/`",则后面的几个scopes是`["write-in-cwd"]`的item就不用显示权限弹窗了。 - -如果用户完成了所有权限弹窗的选择,则判断: - -1. 如果用户提交的结果中包含deny,则需要用户输入user prompt,按回车手动提交replySession()。 - - 如果用户没有输入user prompt就退出了,或者切换到了其他会话。则重新开始这个会话时,由于会话状态还是ask_permission,则会重新显示权限弹窗,要求用户选择。 -2. 如果用户提交的结果中不包含deny,则以`/continue`作为UserPromptContent.text内容,前端自动提交replySession()。 - - -## 后端流程 - -后端主要是对replySession()和activateSession()进行升级: - -1. 支持传入UserPromptContent.permissions和alwaysAllows -2. 如果UserPromptContent.alwaysAllows非空,将其中的scopes追加写入项目级别的settings.json配置文件(`permissions.allow`字段),避免重复写入已存在的项。 -3. 检查当前会话消息列表末尾是否存在连续的role为assistant的有tool_calls的消息,也就是"待执行消息"。如果没有,则走现有流程。 -4. 对于每一条待执行消息,先检查UserPromptContent.permissions中对应的toolCallId的用户授权是allow还是deny - - 如果是allow,则正常执行这个toolCall - - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限。例如: - ``` - { - "ok": false, - "name": "edit", - "error": "用户已禁用了在项目目录之外修改文件的权限,请不要尝试用任何方式修改目录之外的文件" - } - ``` -5. 如果对于某条待执行消息,在UserPromptContent.permissions没有出现对应的toolCallId的用户授权,则检查它的 SessionMessage.meta.permissions[].permission 是allow还是deny还是ask - - 如果是allow,则正常执行这个toolCall - - 如果是deny,则直接返回失败结果,报错信息提示LLM用户禁用相关权限 - - 如果是ask,则直接返回失败结果,报错信息提示LLM用户未授权执行。例如: - ``` - { - "ok": false, - "name": "edit", - "error": "用户暂未授权执行,如果有必要,可重新尝试执行" - } - ``` - - 如果不存在,则正常执行这个toolCall(兼容老版本会话数据) -6. 当LLM返回了新的待执行消息时,不要立即执行,而是: - 1. 根据配置的permissions和defaultMode,计算出SessionMessage.meta.permissions字段 - 2. 如果存在一个待执行消息的SessionMessage.meta.permissions[].permission是ask,则把SessionEntry.status设置为"ask_permission",并设置好SessionEntry.askPermissions,然后退出activateSession,这样就回到了上面的前端流程。 From d07d225a072ffea03bcac41d5b6ab70bc46575cb Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 25 May 2026 16:42:02 +0800 Subject: [PATCH 21/21] 0.1.25 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12b9abc..dfa3fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.24", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.24", + "version": "0.1.25", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index ef70520..71c171c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.24", + "version": "0.1.25", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module",