From 5e3540c2d503b0bfb3d6405b8b37832363e74226 Mon Sep 17 00:00:00 2001
From: Lellansin
Date: Sat, 16 May 2026 15:20:08 +0800
Subject: [PATCH 01/95] feat: add Ctrl+O to view live process stdout in
fullscreen overlay
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add onProcessStdout callback chain through executor → bash-handler → session → App
- Stream real-time stdout/stderr from bash commands to UI ref (capped at 1MB)
- Add ProcessStdoutView fullscreen overlay with scroll support
- Bind Ctrl+O in PromptInput to toggle the stdout view
- Footer hint shows 'ctrl+o view output' when a process is running
---
package-lock.json | 814 +++++++----------------------------
src/session.ts | 4 +
src/tools/bash-handler.ts | 4 +
src/tools/executor.ts | 3 +
src/ui/App.tsx | 37 +-
src/ui/ProcessStdoutView.tsx | 109 +++++
src/ui/PromptInput.tsx | 21 +-
7 files changed, 327 insertions(+), 665 deletions(-)
create mode 100644 src/ui/ProcessStdoutView.tsx
diff --git a/package-lock.json b/package-lock.json
index 958eed7..f9caecd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -846,13 +846,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/@eslint/eslintrc/node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "license": "Python-2.0"
- },
"node_modules/@eslint/eslintrc/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
@@ -863,19 +856,6 @@
"node": ">= 4"
}
},
- "node_modules/@eslint/eslintrc/node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
"node_modules/@eslint/js": {
"version": "9.39.4",
"resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz",
@@ -1060,13 +1040,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.8.0",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.8.0.tgz",
+ "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.19.0"
+ "undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/react": {
@@ -1086,17 +1066,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
- "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
+ "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.59.2",
- "@typescript-eslint/type-utils": "8.59.2",
- "@typescript-eslint/utils": "8.59.2",
- "@typescript-eslint/visitor-keys": "8.59.2",
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/type-utils": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -1109,22 +1089,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.59.2",
+ "@typescript-eslint/parser": "^8.59.3",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz",
- "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.3.tgz",
+ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.59.2",
- "@typescript-eslint/types": "8.59.2",
- "@typescript-eslint/typescript-estree": "8.59.2",
- "@typescript-eslint/visitor-keys": "8.59.2",
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
"debug": "^4.4.3"
},
"engines": {
@@ -1140,14 +1120,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
- "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
+ "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.59.2",
- "@typescript-eslint/types": "^8.59.2",
+ "@typescript-eslint/tsconfig-utils": "^8.59.3",
+ "@typescript-eslint/types": "^8.59.3",
"debug": "^4.4.3"
},
"engines": {
@@ -1162,14 +1142,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
- "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
+ "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.2",
- "@typescript-eslint/visitor-keys": "8.59.2"
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1180,9 +1160,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
- "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
+ "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1197,15 +1177,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
- "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
+ "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.2",
- "@typescript-eslint/typescript-estree": "8.59.2",
- "@typescript-eslint/utils": "8.59.2",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1222,9 +1202,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz",
- "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz",
+ "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1236,16 +1216,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
- "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
+ "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.59.2",
- "@typescript-eslint/tsconfig-utils": "8.59.2",
- "@typescript-eslint/types": "8.59.2",
- "@typescript-eslint/visitor-keys": "8.59.2",
+ "@typescript-eslint/project-service": "8.59.3",
+ "@typescript-eslint/tsconfig-utils": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1316,16 +1296,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz",
- "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz",
+ "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.59.2",
- "@typescript-eslint/types": "8.59.2",
- "@typescript-eslint/typescript-estree": "8.59.2"
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1340,13 +1320,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
- "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
+ "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/types": "8.59.3",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1450,13 +1430,11 @@
}
},
"node_modules/argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "license": "MIT",
- "dependencies": {
- "sprintf-js": "~1.0.2"
- }
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
},
"node_modules/auto-bind": {
"version": "5.0.1",
@@ -1725,7 +1703,7 @@
},
"node_modules/ejs": {
"version": "5.0.2",
- "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz",
+ "resolved": "https://registry.npmmirror.com/ejs/-/ejs-5.0.2.tgz",
"integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==",
"license": "Apache-2.0",
"bin": {
@@ -1736,9 +1714,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.353",
- "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
- "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
+ "version": "1.5.356",
+ "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
+ "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==",
"dev": true,
"license": "ISC"
},
@@ -1824,12 +1802,16 @@
}
},
"node_modules/escape-string-regexp": {
- "version": "2.0.0",
- "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
- "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
"license": "MIT",
"engines": {
- "node": ">=8"
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
@@ -1991,19 +1973,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/eslint/node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/eslint/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
@@ -2226,9 +2195,9 @@
}
},
"node_modules/get-east-asian-width": {
- "version": "1.5.0",
- "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
- "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -2237,22 +2206,9 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.14.0",
- "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
- "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
- }
- },
"node_modules/glob": {
"version": "13.0.6",
- "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
@@ -2283,7 +2239,7 @@
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
- "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
@@ -2293,7 +2249,7 @@
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6",
- "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
@@ -2306,7 +2262,7 @@
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.5",
- "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
@@ -2361,6 +2317,28 @@
"node": ">=6.0"
}
},
+ "node_modules/gray-matter/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/gray-matter/node_modules/js-yaml": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
@@ -2453,9 +2431,9 @@
}
},
"node_modules/ink": {
- "version": "7.0.1",
- "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.1.tgz",
- "integrity": "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w==",
+ "version": "7.0.3",
+ "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.3.tgz",
+ "integrity": "sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
@@ -2598,13 +2576,13 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "3.14.2",
- "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz",
- "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
+ "argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
@@ -2971,7 +2949,7 @@
},
"node_modules/minipass": {
"version": "7.1.3",
- "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
@@ -2994,9 +2972,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.38",
- "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz",
- "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "version": "2.0.44",
+ "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz",
+ "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"dev": true,
"license": "MIT"
},
@@ -3016,9 +2994,9 @@
}
},
"node_modules/openai": {
- "version": "6.35.0",
- "resolved": "https://registry.npmmirror.com/openai/-/openai-6.35.0.tgz",
- "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==",
+ "version": "6.37.0",
+ "resolved": "https://registry.npmmirror.com/openai/-/openai-6.37.0.tgz",
+ "integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
@@ -3130,7 +3108,7 @@
},
"node_modules/path-scurry": {
"version": "2.0.2",
- "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
@@ -3147,7 +3125,7 @@
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.3.6",
- "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"dev": true,
"license": "BlueOak-1.0.0",
@@ -3212,9 +3190,9 @@
}
},
"node_modules/react": {
- "version": "19.2.5",
- "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz",
- "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "version": "19.2.6",
+ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz",
+ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3245,16 +3223,6 @@
"node": ">=4"
}
},
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
- }
- },
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz",
@@ -3370,6 +3338,15 @@
"node": ">=10"
}
},
+ "node_modules/stack-utils/node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz",
@@ -3527,14 +3504,13 @@
}
},
"node_modules/tsx": {
- "version": "4.21.0",
- "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "version": "4.22.0",
+ "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.22.0.tgz",
+ "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "~0.27.0",
- "get-tsconfig": "^4.7.5"
+ "esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
@@ -3546,490 +3522,6 @@
"fsevents": "~2.3.3"
}
},
- "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tsx/node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.7",
- "@esbuild/android-arm": "0.27.7",
- "@esbuild/android-arm64": "0.27.7",
- "@esbuild/android-x64": "0.27.7",
- "@esbuild/darwin-arm64": "0.27.7",
- "@esbuild/darwin-x64": "0.27.7",
- "@esbuild/freebsd-arm64": "0.27.7",
- "@esbuild/freebsd-x64": "0.27.7",
- "@esbuild/linux-arm": "0.27.7",
- "@esbuild/linux-arm64": "0.27.7",
- "@esbuild/linux-ia32": "0.27.7",
- "@esbuild/linux-loong64": "0.27.7",
- "@esbuild/linux-mips64el": "0.27.7",
- "@esbuild/linux-ppc64": "0.27.7",
- "@esbuild/linux-riscv64": "0.27.7",
- "@esbuild/linux-s390x": "0.27.7",
- "@esbuild/linux-x64": "0.27.7",
- "@esbuild/netbsd-arm64": "0.27.7",
- "@esbuild/netbsd-x64": "0.27.7",
- "@esbuild/openbsd-arm64": "0.27.7",
- "@esbuild/openbsd-x64": "0.27.7",
- "@esbuild/openharmony-arm64": "0.27.7",
- "@esbuild/sunos-x64": "0.27.7",
- "@esbuild/win32-arm64": "0.27.7",
- "@esbuild/win32-ia32": "0.27.7",
- "@esbuild/win32-x64": "0.27.7"
- }
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
@@ -4073,16 +3565,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.59.2",
- "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz",
- "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
+ "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.59.2",
- "@typescript-eslint/parser": "8.59.2",
- "@typescript-eslint/typescript-estree": "8.59.2",
- "@typescript-eslint/utils": "8.59.2"
+ "@typescript-eslint/eslint-plugin": "8.59.3",
+ "@typescript-eslint/parser": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4097,9 +3589,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.19.2",
- "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
- "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
@@ -4203,9 +3695,9 @@
}
},
"node_modules/ws": {
- "version": "8.20.0",
- "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
- "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "version": "8.20.1",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.1.tgz",
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -4231,9 +3723,9 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.4",
- "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz",
- "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
+ "version": "2.9.0",
+ "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"optional": true,
diff --git a/src/session.ts b/src/session.ts
index 8c078f3..6da8835 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -197,6 +197,7 @@ type SessionManagerOptions = {
onSessionEntryUpdated?: (entry: SessionEntry) => void;
onLlmStreamProgress?: (progress: LlmStreamProgress) => void;
onMcpStatusChanged?: () => void;
+ onProcessStdout?: (pid: number, chunk: string) => void;
};
export type LlmStreamProgress = {
@@ -220,6 +221,7 @@ export class SessionManager {
private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void;
private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void;
private readonly onMcpStatusChanged?: () => void;
+ private readonly onProcessStdout?: (pid: number, chunk: string) => void;
private activeSessionId: string | null = null;
private activePromptController: AbortController | null = null;
private readonly sessionControllers = new Map();
@@ -235,6 +237,7 @@ export class SessionManager {
this.onSessionEntryUpdated = options.onSessionEntryUpdated;
this.onLlmStreamProgress = options.onLlmStreamProgress;
this.onMcpStatusChanged = options.onMcpStatusChanged;
+ this.onProcessStdout = options.onProcessStdout;
this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager);
this.mcpManager.prepare(this.getResolvedSettings().mcpServers);
}
@@ -1699,6 +1702,7 @@ ${skillMd}
const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, {
onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command),
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
+ onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
shouldStop: () => this.isInterrupted(sessionId),
});
if (this.isInterrupted(sessionId)) {
diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts
index 95e7e76..071da53 100644
--- a/src/tools/bash-handler.ts
+++ b/src/tools/bash-handler.ts
@@ -124,9 +124,13 @@ async function executeShellCommand(
child.stdout?.on("data", (chunk: string | Buffer) => {
stdout = appendChunk(stdout, chunk);
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
+ context.onProcessStdout?.(pid as number, text);
});
child.stderr?.on("data", (chunk: string | Buffer) => {
stderr = appendChunk(stderr, chunk);
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
+ context.onProcessStdout?.(pid as number, text);
});
child.on("error", (spawnError) => {
diff --git a/src/tools/executor.ts b/src/tools/executor.ts
index bc2d7d8..e6018d9 100644
--- a/src/tools/executor.ts
+++ b/src/tools/executor.ts
@@ -37,11 +37,13 @@ export type ToolExecutionContext = {
createOpenAIClient?: CreateOpenAIClient;
onProcessStart?: (processId: string | number, command: string) => void;
onProcessExit?: (processId: string | number) => void;
+ onProcessStdout?: (processId: string | number, chunk: string) => void;
};
export type ToolExecutionHooks = {
onProcessStart?: (processId: string | number, command: string) => void;
onProcessExit?: (processId: string | number) => void;
+ onProcessStdout?: (processId: string | number, chunk: string) => void;
shouldStop?: () => boolean;
};
@@ -195,6 +197,7 @@ export class ToolExecutor {
createOpenAIClient: this.createOpenAIClient,
onProcessStart: hooks?.onProcessStart,
onProcessExit: hooks?.onProcessExit,
+ onProcessStdout: hooks?.onProcessStdout,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 8c5c375..c864187 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -30,6 +30,7 @@ import { findExpandedThinkingId } from "./thinkingState";
import { WelcomeScreen } from "./WelcomeScreen";
import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
import { McpStatusList } from "./McpStatusList";
+import { ProcessStdoutView } from "./ProcessStdoutView";
import {
findPendingAskUserQuestion,
formatAskUserQuestionAnswers,
@@ -69,6 +70,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot));
const [nowTick, setNowTick] = useState(0);
const [mcpStatuses, setMcpStatuses] = useState>([]);
+ const [showProcessStdout, setShowProcessStdout] = useState(false);
+ const processStdoutRef = useRef
Deep Code CLI
-[English](./README_en.md) · 中文
+[English](README-en.md) · 中文
From 3fef0fc5137af49f218237fa0e919159fb231122 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 10:09:38 +0800
Subject: [PATCH 32/95] feat(notify): pass STATUS, FAIL_REASON, BODY as env
vars to notify hook
- Add NotifyContext type with status, failReason, body fields
- buildNotifyEnv injects STATUS, FAIL_REASON, BODY when provided
- maybeNotifyTaskCompletion extracts last assistant message as BODY
- launchNotifyScript accepts optional context parameter
- Add unit tests for new context env var injection
- Update docs with env variable table and iTerm2/macOS notify examples
---
docs/configuration.md | 34 ++++++
docs/configuration_en.md | 34 ++++++
src/common/notify.ts | 38 ++++++-
src/session.ts | 18 +++-
src/tests/session.test.ts | 144 ++++++++++++++++++++++++++
src/tests/settings-and-notify.test.ts | 65 +++++++++++-
6 files changed, 324 insertions(+), 9 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index f8e52c3..45aaab0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -67,12 +67,46 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。
+通知脚本执行时,会通过环境变量注入以下上下文信息:
+
+| 环境变量 | 说明 |
+|----------|------|
+| `DURATION` | 会话耗时,单位秒(整数) |
+| `STATUS` | 会话状态:`"completed"` 或 `"failed"` |
+| `FAIL_REASON` | 失败原因(仅失败时设置) |
+| `BODY` | 最后一条 AI 助手回复的文本内容 |
+| `TITLE` | 会话标题(对应 resume 列表中的标题) |
+
```json
{
"notify": "/path/to/slack-notify.sh"
}
```
+**iTerm2 终端通知示例**:
+
+如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`):
+
+```bash
+#!/bin/bash
+# iTerm2 OSC 9 通知
+echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+**macOS 系统通知示例**:
+
+```bash
+#!/bin/bash
+# macOS 系统通知
+osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
+```
+
#### `webSearchTool` — 自定义联网搜索
Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径:
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 369f8e4..606fcab 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -67,12 +67,46 @@ When thinking mode is enabled, controls the depth of the model’s reasoning:
Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message).
+The following context is injected as environment variables when the notify script runs:
+
+| Variable | Description |
+|----------|-------------|
+| `DURATION` | Session duration in seconds (integer) |
+| `STATUS` | Session status: `"completed"` or `"failed"` |
+| `FAIL_REASON` | Failure reason (only set on failure) |
+| `BODY` | The text content of the last AI assistant reply |
+| `TITLE` | Session title (matches the resume list title) |
+
```json
{
"notify": "/path/to/slack-notify.sh"
}
```
+**iTerm2 Notification Example**:
+
+On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`):
+
+```bash
+#!/bin/bash
+# iTerm2 OSC 9 notification
+echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+**macOS System Notification Example**:
+
+```bash
+#!/bin/bash
+# macOS system notification
+osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
+```
+
#### `webSearchTool` — Custom Web Search
Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script:
diff --git a/src/common/notify.ts b/src/common/notify.ts
index 8878c50..d1b541b 100644
--- a/src/common/notify.ts
+++ b/src/common/notify.ts
@@ -16,11 +16,40 @@ export function formatDurationSeconds(durationMs: number): string {
return String(Math.floor(safeMs / 1000));
}
-export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
- return {
+export type NotifyContext = {
+ status?: string;
+ failReason?: string;
+ body?: string;
+ title?: string;
+};
+
+export function buildNotifyEnv(
+ durationMs: number,
+ baseEnv: NodeJS.ProcessEnv = process.env,
+ context: NotifyContext = {}
+): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = {
...baseEnv,
DURATION: formatDurationSeconds(durationMs),
};
+ delete env.STATUS;
+ delete env.FAIL_REASON;
+ delete env.BODY;
+ delete env.TITLE;
+
+ if (context.status) {
+ env.STATUS = context.status;
+ }
+ if (context.failReason) {
+ env.FAIL_REASON = context.failReason;
+ }
+ if (context.body) {
+ env.BODY = context.body;
+ }
+ if (context.title) {
+ env.TITLE = context.title;
+ }
+ return env;
}
export function launchNotifyScript(
@@ -28,7 +57,8 @@ export function launchNotifyScript(
durationMs: number,
workingDirectory?: string,
spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn,
- configuredEnv: Record = {}
+ configuredEnv: Record = {},
+ context: NotifyContext = {}
): void {
const commandPath = notifyPath?.trim();
if (!commandPath) {
@@ -38,7 +68,7 @@ export function launchNotifyScript(
const options = {
cwd: workingDirectory,
detached: process.platform !== "win32",
- env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }),
+ env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context),
stdio: "ignore" as const,
};
diff --git a/src/session.ts b/src/session.ts
index 96a9adb..3a6e13b 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -2124,7 +2124,23 @@ ${skillMd}
return;
}
- launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv);
+ // Find the last assistant message body for the BODY env variable.
+ let body: string | undefined;
+ const messages = this.listSessionMessages(sessionId);
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg && msg.role === "assistant" && msg.content) {
+ body = msg.content;
+ break;
+ }
+ }
+
+ launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, {
+ status: session.status,
+ failReason: session.failReason ?? undefined,
+ body,
+ title: session.summary ?? undefined,
+ });
}
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index b7eadae..d079949 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -783,6 +783,68 @@ test("reporting a new prompt does not warn when the background request fails", a
assert.deepEqual(warnings, []);
});
+test(
+ "SessionManager notifies successful completion with session context",
+ { skip: process.platform === "win32" },
+ async () => {
+ const workspace = createTempDir("deepcode-notify-success-workspace-");
+ const home = createTempDir("deepcode-notify-success-home-");
+ setHomeDir(home);
+
+ const notifyOutput = path.join(workspace, "notify.jsonl");
+ const notifyScript = createNotifyRecorderScript(workspace);
+ const manager = createNotifyingSessionManager(
+ workspace,
+ [createChatResponse("final answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })],
+ notifyScript,
+ notifyOutput
+ );
+
+ await manager.createSession({ text: "notify success" });
+
+ const records = await waitForNotifyRecords(notifyOutput, 1);
+ assert.equal(records[0]?.STATUS, "completed");
+ assert.equal(records[0]?.FAIL_REASON, null);
+ assert.equal(records[0]?.BODY, "final answer");
+ assert.equal(records[0]?.TITLE, "notify success");
+ assert.match(String(records[0]?.DURATION), /^\d+$/);
+ }
+);
+
+test(
+ "SessionManager notifies failed completion with failure context",
+ { skip: process.platform === "win32" },
+ async () => {
+ const workspace = createTempDir("deepcode-notify-failure-workspace-");
+ const home = createTempDir("deepcode-notify-failure-home-");
+ setHomeDir(home);
+
+ const notifyOutput = path.join(workspace, "notify.jsonl");
+ const notifyScript = createNotifyRecorderScript(workspace);
+ const manager = createNotifyingSessionManager(
+ workspace,
+ [
+ createChatResponse("first answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ new Error("second request failed"),
+ ],
+ notifyScript,
+ notifyOutput
+ );
+
+ const sessionId = await manager.createSession({ text: "notify failure" });
+ await waitForNotifyRecords(notifyOutput, 1);
+ await manager.replySession(sessionId, { text: "second prompt" });
+
+ const records = await waitForNotifyRecords(notifyOutput, 2);
+ const failedRecord = records[1];
+ assert.equal(failedRecord?.STATUS, "failed");
+ assert.equal(failedRecord?.FAIL_REASON, "second request failed");
+ assert.equal(failedRecord?.BODY, "first answer");
+ assert.notEqual(failedRecord?.BODY, "stale-body");
+ assert.equal(failedRecord?.TITLE, "notify failure");
+ }
+);
+
test("replySession continues without appending /continue as a user message", async () => {
const workspace = createTempDir("deepcode-continue-workspace-");
const home = createTempDir("deepcode-continue-home-");
@@ -1657,6 +1719,49 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa
});
}
+function createNotifyingSessionManager(
+ projectRoot: string,
+ responses: unknown[],
+ notifyPath: string,
+ notifyOutput: string
+): SessionManager {
+ const client = {
+ chat: {
+ completions: {
+ create: async () => {
+ const response = responses.shift();
+ assert.ok(response, "expected a queued chat response");
+ if (response instanceof Error) {
+ throw response;
+ }
+ return response;
+ },
+ },
+ },
+ };
+
+ return new SessionManager({
+ projectRoot,
+ createOpenAIClient: () => ({
+ client: client as any,
+ model: "test-model",
+ baseURL: "https://api.deepseek.com",
+ thinkingEnabled: false,
+ notify: notifyPath,
+ env: {
+ NOTIFY_OUTPUT: notifyOutput,
+ STATUS: "stale-status",
+ FAIL_REASON: "stale-failure",
+ BODY: "stale-body",
+ TITLE: "stale-title",
+ },
+ }),
+ getResolvedSettings: () => ({ model: "test-model" }),
+ renderMarkdown: (text) => text,
+ onAssistantMessage: () => {},
+ });
+}
+
function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager {
const client = {
chat: {
@@ -1740,6 +1845,45 @@ function createTempDir(prefix: string): string {
return dir;
}
+function createNotifyRecorderScript(dir: string): string {
+ const scriptPath = path.join(dir, "notify-recorder.cjs");
+ fs.writeFileSync(
+ scriptPath,
+ `#!/usr/bin/env node
+const fs = require("fs");
+const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"];
+const record = {};
+for (const key of keys) {
+ record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null;
+}
+fs.appendFileSync(process.env.NOTIFY_OUTPUT, JSON.stringify(record) + "\\n", "utf8");
+`,
+ "utf8"
+ );
+ fs.chmodSync(scriptPath, 0o755);
+ return scriptPath;
+}
+
+async function waitForNotifyRecords(
+ outputPath: string,
+ expectedCount: number
+): Promise>> {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (fs.existsSync(outputPath)) {
+ const records = fs
+ .readFileSync(outputPath, "utf8")
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as Record);
+ if (records.length >= expectedCount) {
+ return records;
+ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ }
+ assert.fail(`expected ${expectedCount} notify records in ${outputPath}`);
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts
index 6990288..202f849 100644
--- a/src/tests/settings-and-notify.test.ts
+++ b/src/tests/settings-and-notify.test.ts
@@ -1,6 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify";
+import {
+ buildNotifyEnv,
+ formatDurationSeconds,
+ launchNotifyScript,
+ type NotifyContext,
+ type NotifySpawn,
+} from "../common/notify";
import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings";
const TEST_PROCESS_ENV = {};
@@ -358,14 +364,52 @@ test("formatDurationSeconds preserves sub-second precision and trims trailing ze
assert.equal(formatDurationSeconds(4000), "4");
});
-test("buildNotifyEnv injects DURATION", () => {
+test("buildNotifyEnv injects DURATION without context", () => {
const env = buildNotifyEnv(2750, { HOME: "/tmp/home" });
assert.equal(env.HOME, "/tmp/home");
assert.equal(env.DURATION, "2");
+ assert.equal(env.STATUS, undefined);
+ assert.equal(env.FAIL_REASON, undefined);
+ assert.equal(env.BODY, undefined);
+ assert.equal(env.TITLE, undefined);
+});
+
+test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => {
+ const context: NotifyContext = {
+ status: "failed",
+ failReason: "API key not found",
+ body: "Hello, this is the last assistant message.",
+ title: "Fix login bug",
+ };
+ const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context);
+ assert.equal(env.HOME, "/tmp/home");
+ assert.equal(env.DURATION, "5");
+ assert.equal(env.STATUS, "failed");
+ assert.equal(env.FAIL_REASON, "API key not found");
+ assert.equal(env.BODY, "Hello, this is the last assistant message.");
+ assert.equal(env.TITLE, "Fix login bug");
+});
+
+test("buildNotifyEnv omits optional context fields when not provided", () => {
+ const env = buildNotifyEnv(
+ 1000,
+ {
+ HOME: "/tmp/home",
+ STATUS: "stale-status",
+ FAIL_REASON: "stale-failure",
+ BODY: "stale-body",
+ TITLE: "stale-title",
+ },
+ { status: "completed" }
+ );
+ assert.equal(env.STATUS, "completed");
+ assert.equal(env.FAIL_REASON, undefined);
+ assert.equal(env.BODY, undefined);
+ assert.equal(env.TITLE, undefined);
});
test(
- "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts",
+ "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts",
{ skip: process.platform === "win32" },
() => {
const calls: Array<{
@@ -390,7 +434,13 @@ test(
};
};
- launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" });
+ const context: NotifyContext = {
+ status: "completed",
+ body: "Task finished successfully.",
+ title: "Fix login bug",
+ };
+
+ launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }, context);
assert.equal(calls.length, 2);
assert.equal(calls[0]?.command, "/tmp/notify.sh");
@@ -398,9 +448,16 @@ test(
assert.equal(calls[0]?.options.cwd, "/tmp/project");
assert.equal(calls[0]?.options.env?.DURATION, "2");
assert.equal(calls[0]?.options.env?.WEBHOOK, "configured");
+ assert.equal(calls[0]?.options.env?.STATUS, "completed");
+ assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined);
+ assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully.");
+ assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug");
assert.equal(calls[1]?.command, "/bin/sh");
assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]);
assert.equal(calls[1]?.options.cwd, "/tmp/project");
assert.equal(calls[1]?.options.env?.DURATION, "2");
+ assert.equal(calls[1]?.options.env?.STATUS, "completed");
+ assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully.");
+ assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug");
}
);
From a3ff70e82d548a8c1273ea377844f078cbd0ae00 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 10:43:55 +0800
Subject: [PATCH 33/95] docs(notify): add Windows Terminal, Linux, and msg
popup notification examples; add edge-case tests
- Expand OSC 9 example to cover both iTerm2 and Windows Terminal
- Add .bat example for Windows Terminal users
- Add Linux notify-send example
- Add Windows msg popup notification example
- Add tests for empty-string rejection and special character preservation
---
docs/configuration.md | 32 +++++++++++++++++++++++----
docs/configuration_en.md | 32 +++++++++++++++++++++++----
src/tests/settings-and-notify.test.ts | 27 ++++++++++++++++++++++
3 files changed, 83 insertions(+), 8 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 45aaab0..7c2880c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,14 +83,14 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
}
```
-**iTerm2 终端通知示例**:
+**终端内通知示例(支持 iTerm2 / Windows Terminal)**:
-如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`):
+如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。创建以下脚本(如 `~/.deepcode/notify.sh`):
```bash
#!/bin/bash
-# iTerm2 OSC 9 通知
-echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+# iTerm2 / Windows Terminal OSC 9 通知
+printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
```
```json
@@ -99,6 +99,14 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
}
```
+Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本:
+
+```batch
+@echo off
+REM Windows Terminal OSC 9 通知
+echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
+```
+
**macOS 系统通知示例**:
```bash
@@ -107,6 +115,22 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
```
+**Linux 系统通知示例**(需安装 `libnotify-bin`):
+
+```bash
+#!/bin/bash
+# Linux notify-send 通知
+notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s"
+```
+
+**Windows msg 弹窗通知示例**:
+
+```batch
+@echo off
+REM Windows msg 弹窗通知
+msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
+```
+
#### `webSearchTool` — 自定义联网搜索
Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径:
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 606fcab..5d931f4 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -83,14 +83,14 @@ The following context is injected as environment variables when the notify scrip
}
```
-**iTerm2 Notification Example**:
+**Terminal Notification Example (iTerm2 / Windows Terminal)**:
-On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`):
+On iTerm2 or Windows Terminal you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. Create a script (e.g., `~/.deepcode/notify.sh`):
```bash
#!/bin/bash
-# iTerm2 OSC 9 notification
-echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
+# iTerm2 / Windows Terminal OSC 9 notification
+printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
```
```json
@@ -99,6 +99,14 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
}
```
+Windows users on Git Bash can use the same script; alternatively create a `.bat` script:
+
+```batch
+@echo off
+REM Windows Terminal OSC 9 notification
+echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
+```
+
**macOS System Notification Example**:
```bash
@@ -107,6 +115,22 @@ echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
```
+**Linux System Notification Example** (requires `libnotify-bin`):
+
+```bash
+#!/bin/bash
+# Linux notify-send notification
+notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s"
+```
+
+**Windows msg Popup Notification Example**:
+
+```batch
+@echo off
+REM Windows msg popup notification
+msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
+```
+
#### `webSearchTool` — Custom Web Search
Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script:
diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts
index 202f849..1707aff 100644
--- a/src/tests/settings-and-notify.test.ts
+++ b/src/tests/settings-and-notify.test.ts
@@ -408,6 +408,33 @@ test("buildNotifyEnv omits optional context fields when not provided", () => {
assert.equal(env.TITLE, undefined);
});
+test("buildNotifyEnv ignores empty strings in context", () => {
+ const env = buildNotifyEnv(
+ 1000,
+ { HOME: "/tmp/home" },
+ {
+ status: "",
+ failReason: "",
+ body: "",
+ title: "",
+ }
+ );
+ assert.equal(env.STATUS, undefined);
+ assert.equal(env.FAIL_REASON, undefined);
+ assert.equal(env.BODY, undefined);
+ assert.equal(env.TITLE, undefined);
+});
+
+test("buildNotifyEnv preserves special characters in body and title", () => {
+ const context: NotifyContext = {
+ body: 'Line 1\nLine 2\tindented "quoted"',
+ title: "Fix: login & signup (urgent)",
+ };
+ const env = buildNotifyEnv(1000, {}, context);
+ assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"');
+ assert.equal(env.TITLE, "Fix: login & signup (urgent)");
+});
+
test(
"launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts",
{ skip: process.platform === "win32" },
From 479606f6a7087398302334996e95cb8eb2d841b3 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 13:33:28 +0800
Subject: [PATCH 34/95] docs(notify): replace terminal notification examples
with Feishu webhook example
- Remove iTerm2/Windows Terminal OSC 9, macOS osascript, Linux notify-send, and Windows msg examples (OSC 9 is not compatible with current spawn+stdio:ignore architecture)
- Add Feishu (Lark) webhook notification example in both Chinese and English docs
- Keep the env variable table (DURATION, STATUS, FAIL_REASON, BODY, TITLE) unchanged
---
docs/configuration.md | 62 ++++++++++++++--------------------------
docs/configuration_en.md | 62 ++++++++++++++--------------------------
2 files changed, 44 insertions(+), 80 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 7c2880c..b05a44f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,53 +83,35 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
}
```
-**终端内通知示例(支持 iTerm2 / Windows Terminal)**:
+**飞书 Webhook 通知示例**:
-如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。创建以下脚本(如 `~/.deepcode/notify.sh`):
+`node` 构建 JSON(自动转义特殊字符),`curl` 发送:
```bash
#!/bin/bash
-# iTerm2 / Windows Terminal OSC 9 通知
-printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
-```
-
-```json
-{
- "notify": "/Users/you/.deepcode/notify.sh"
-}
-```
-
-Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本:
-
-```batch
-@echo off
-REM Windows Terminal OSC 9 通知
-echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
-```
-
-**macOS 系统通知示例**:
-
-```bash
-#!/bin/bash
-# macOS 系统通知
-osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
-```
-
-**Linux 系统通知示例**(需安装 `libnotify-bin`):
+WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
+
+STATUS="${STATUS:-completed}"
+TITLE="${TITLE:-Untitled}"
+DURATION="${DURATION:-0}"
+BODY="${BODY:-(no output)}"
+
+PAYLOAD=$(node -e "
+process.stdout.write(JSON.stringify({
+ msg_type: 'interactive',
+ card: {
+ header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
+ elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
+ }
+}))
+")
-```bash
-#!/bin/bash
-# Linux notify-send 通知
-notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s"
+curl -s -X POST "$WEBHOOK_URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
```
-**Windows msg 弹窗通知示例**:
-
-```batch
-@echo off
-REM Windows msg 弹窗通知
-msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
-```
+将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。更多变量参考上表。同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。
#### `webSearchTool` — 自定义联网搜索
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 5d931f4..4f2f94d 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -83,53 +83,35 @@ The following context is injected as environment variables when the notify scrip
}
```
-**Terminal Notification Example (iTerm2 / Windows Terminal)**:
+**Feishu (Lark) Webhook Notification Example**:
-On iTerm2 or Windows Terminal you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. Create a script (e.g., `~/.deepcode/notify.sh`):
+`node` builds the JSON (auto-escapes special characters), `curl` sends it:
```bash
#!/bin/bash
-# iTerm2 / Windows Terminal OSC 9 notification
-printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
-```
-
-```json
-{
- "notify": "/Users/you/.deepcode/notify.sh"
-}
-```
-
-Windows users on Git Bash can use the same script; alternatively create a `.bat` script:
-
-```batch
-@echo off
-REM Windows Terminal OSC 9 notification
-echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
-```
-
-**macOS System Notification Example**:
-
-```bash
-#!/bin/bash
-# macOS system notification
-osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
-```
-
-**Linux System Notification Example** (requires `libnotify-bin`):
+WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
+
+STATUS="${STATUS:-completed}"
+TITLE="${TITLE:-Untitled}"
+DURATION="${DURATION:-0}"
+BODY="${BODY:-(no output)}"
+
+PAYLOAD=$(node -e "
+process.stdout.write(JSON.stringify({
+ msg_type: 'interactive',
+ card: {
+ header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
+ elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
+ }
+}))
+")
-```bash
-#!/bin/bash
-# Linux notify-send notification
-notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s"
+curl -s -X POST "$WEBHOOK_URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
```
-**Windows msg Popup Notification Example**:
-
-```batch
-@echo off
-REM Windows msg popup notification
-msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
-```
+Replace `WEBHOOK_URL` with your Feishu bot webhook URL. See the table above for all available variables. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format.
#### `webSearchTool` — Custom Web Search
From 7e5eeda26829b14eb3ed503b550db06c1145acf6 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Tue, 19 May 2026 15:05:00 +0800
Subject: [PATCH 35/95] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20raw=20?=
=?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E6=B6=88=E6=81=AF=E7=9B=B4=E6=8E=A5?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 Raw 模式下,使用 process.stdout.write 直接输出所有可见消息
- 清屏并重置光标位置,避免 Ink 组件干扰
- 显示提示信息,指导用户按 ESC 退出 raw 模式
- 优化终端尺寸变化时的重绘逻辑
- 更新依赖,确保 raw 模式变动触发重新渲染
---
src/ui/App.tsx | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 9189df6..e39fd03 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -434,8 +434,31 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
}
lastRenderedColumnsRef.current = stableColumns;
+ 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");
+ 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"));
+ }
+ return;
+ }
+
// Force full redraw on terminal resize to avoid stale wrapped rows.
writeRef.current("\u001B[2J\u001B[H");
+
setMessages([]);
setShowWelcome(false);
setWelcomeNonce((n) => n + 1);
@@ -447,7 +470,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
setMessages(nextMessages);
setShowWelcome(true);
}, 0);
- }, [busy, sessionManager, stableColumns, stdout]);
+ }, [busy, mode, sessionManager, stableColumns, stdout]);
+
const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]);
const promptHistory = useMemo(() => {
return messages
From faf10c3e087d214bf863e9df14040176e30de821 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Tue, 19 May 2026 15:12:08 +0800
Subject: [PATCH 36/95] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=AE=BD=E5=BA=A6=E7=9B=B8=E5=85=B3=E7=9A=84?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E5=BC=95=E7=94=A8=E7=AE=A1=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 合并并调整了关于窗口宽度columns的使用,去除了stableColumns状态
- 引用lastRenderedColumnsRef改为直接使用columns,避免延迟更新
- 将多个相关的useRef(writeRef、rawModeRef、messagesRef、processStdoutRef)移至同一位置声明
- 调整useEffect依赖项,改为监听columns代替stableColumns
- 优化RawMode下消息重绘逻辑,确保宽度变化时重新渲染
- 统一了screenWidth的计算逻辑,简化代码结构
---
src/ui/App.tsx | 30 ++++++++++++------------------
1 file changed, 12 insertions(+), 18 deletions(-)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index e39fd03..582abaf 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -55,7 +55,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
const { exit } = useApp();
const { stdout, write } = useStdout();
const { columns } = useWindowSize();
+ const { mode, setMode } = useRawModeContext();
const initialPromptSubmittedRef = useRef(false);
+ const processStdoutRef = useRef>(new Map());
+ const rawModeRef = useRef(mode);
+ const writeRef = useRef(write);
+ const lastRenderedColumnsRef = useRef(null);
+ const messagesRef = useRef([]);
const [view, setView] = useState("chat");
const [busy, setBusy] = useState(false);
const [skills, setSkills] = useState([]);
@@ -74,13 +80,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
const [nowTick, setNowTick] = useState(0);
const [mcpStatuses, setMcpStatuses] = useState>([]);
const [showProcessStdout, setShowProcessStdout] = useState(false);
- const processStdoutRef = useRef>(new Map());
- const { mode, setMode } = useRawModeContext();
- const rawModeRef = useRef(mode);
rawModeRef.current = mode;
-
- const messagesRef = useRef([]);
messagesRef.current = messages;
const sessionManager = useMemo(() => {
@@ -172,7 +173,6 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
};
}, [sessionManager]);
- const writeRef = useRef(write);
writeRef.current = write;
const handlePrompt = useCallback(
async (submission: PromptSubmission) => {
@@ -412,27 +412,21 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
[handleSelectSession, sessionManager, setMode]
);
- const [stableColumns, setStableColumns] = useState(columns);
- useEffect(() => {
- const timer = setTimeout(() => setStableColumns(columns), 100);
- return () => clearTimeout(timer);
- }, [columns]);
- const lastRenderedColumnsRef = useRef(null);
useEffect(() => {
if (!stdout?.isTTY) {
return;
}
- if (stableColumns <= 0) {
+ if (columns <= 0) {
return;
}
if (lastRenderedColumnsRef.current === null) {
- lastRenderedColumnsRef.current = stableColumns;
+ lastRenderedColumnsRef.current = columns;
return;
}
- if (lastRenderedColumnsRef.current === stableColumns) {
+ if (lastRenderedColumnsRef.current === columns) {
return;
}
- lastRenderedColumnsRef.current = stableColumns;
+ lastRenderedColumnsRef.current = columns;
if (mode === RawMode.Raw) {
// In raw mode, re-render all messages directly to stdout at the new width.
@@ -470,9 +464,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
setMessages(nextMessages);
setShowWelcome(true);
}, 0);
- }, [busy, mode, sessionManager, stableColumns, stdout]);
+ }, [busy, mode, sessionManager, columns, stdout]);
- const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]);
+ const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]);
const promptHistory = useMemo(() => {
return messages
.filter((message) => message.role === "user" && typeof message.content === "string")
From 32da2ca695e0ff3e135dcbd591ca156554e97108 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Tue, 19 May 2026 15:28:38 +0800
Subject: [PATCH 37/95] =?UTF-8?q?feat(rawmode):=20=E6=B7=BB=E5=8A=A0=20Raw?=
=?UTF-8?q?Mode=20=E6=8F=8F=E8=BF=B0=E4=BF=A1=E6=81=AF=E4=BB=A5=E5=A2=9E?=
=?UTF-8?q?=E5=BC=BA=E5=8F=AF=E8=AF=BB=E6=80=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/ui/contexts/RawModeContext.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/ui/contexts/RawModeContext.tsx b/src/ui/contexts/RawModeContext.tsx
index 6fbc706..3198a3a 100644
--- a/src/ui/contexts/RawModeContext.tsx
+++ b/src/ui/contexts/RawModeContext.tsx
@@ -10,14 +10,17 @@ export const RAW_COMMAND_MODELS: DropdownMenuItem[] = [
{
label: "Lite mode",
key: RawMode.Lite,
+ description: "Collapse chain-of-thought reasoning.",
},
{
label: "Normal mode",
key: RawMode.None,
+ description: "Show full chain-of-thought reasoning.",
},
{
label: "Raw scrollback mode",
key: RawMode.Raw,
+ description: "Show scrollback mode for copy-friendly terminal selection.",
},
] as const;
From f9d2737f2737d195ab94e587d378c322f13b411a Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Tue, 19 May 2026 16:04:03 +0800
Subject: [PATCH 38/95] docs(notify): extract notification examples to
standalone notify.md
- Add docs/notify.md and docs/notify_en.md with Slack, Feishu, terminal,
macOS, Linux, Windows msg, and custom notification examples
- Simplify notify section in configuration.md / configuration_en.md to
field description + env table + reference to notify docs
- Replace external binfer.net link with docs/notify.md in README FAQ
across README.md, README-zh_CN.md, README-en.md
---
README-en.md | 2 +-
README-zh_CN.md | 2 +-
README.md | 2 +-
docs/configuration.md | 32 +-----
docs/configuration_en.md | 32 +-----
docs/notify.md | 211 +++++++++++++++++++++++++++++++++++++++
docs/notify_en.md | 211 +++++++++++++++++++++++++++++++++++++++
7 files changed, 429 insertions(+), 63 deletions(-)
create mode 100644 docs/notify.md
create mode 100644 docs/notify_en.md
diff --git a/README-en.md b/README-en.md
index 4c78cbd..55d0cf6 100644
--- a/README-en.md
+++ b/README-en.md
@@ -99,7 +99,7 @@ Deep Code supports multimodal input — you can paste images from the clipboard
### How to automatically send a Slack message after a task completes?
-Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, refer to: https://binfer.net/share/jby5xnc-so6g
+Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, see [docs/notify_en.md](docs/notify_en.md).
### How do I enable web search?
diff --git a/README-zh_CN.md b/README-zh_CN.md
index 98346b6..8a427de 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -99,7 +99,7 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前
### 怎样在任务完成后自动给 Slack 发消息?
-编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g
+编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。
### 怎样启用联网搜索功能?
diff --git a/README.md b/README.md
index 98346b6..8a427de 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前
### 怎样在任务完成后自动给 Slack 发消息?
-编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g
+编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。
### 怎样启用联网搜索功能?
diff --git a/docs/configuration.md b/docs/configuration.md
index b05a44f..1cce9a1 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -79,39 +79,11 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
```json
{
- "notify": "/path/to/slack-notify.sh"
+ "notify": "/path/to/notify-script.sh"
}
```
-**飞书 Webhook 通知示例**:
-
-`node` 构建 JSON(自动转义特殊字符),`curl` 发送:
-
-```bash
-#!/bin/bash
-WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
-
-STATUS="${STATUS:-completed}"
-TITLE="${TITLE:-Untitled}"
-DURATION="${DURATION:-0}"
-BODY="${BODY:-(no output)}"
-
-PAYLOAD=$(node -e "
-process.stdout.write(JSON.stringify({
- msg_type: 'interactive',
- card: {
- header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
- elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
- }
-}))
-")
-
-curl -s -X POST "$WEBHOOK_URL" \
- -H "Content-Type: application/json" \
- -d "$PAYLOAD"
-```
-
-将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。更多变量参考上表。同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。
+> 详细的 Slack、飞书、终端通知、系统通知等配置示例,请参阅 [notify.md](notify.md)。
#### `webSearchTool` — 自定义联网搜索
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 4f2f94d..fa396f9 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -79,39 +79,11 @@ The following context is injected as environment variables when the notify scrip
```json
{
- "notify": "/path/to/slack-notify.sh"
+ "notify": "/path/to/notify-script.sh"
}
```
-**Feishu (Lark) Webhook Notification Example**:
-
-`node` builds the JSON (auto-escapes special characters), `curl` sends it:
-
-```bash
-#!/bin/bash
-WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
-
-STATUS="${STATUS:-completed}"
-TITLE="${TITLE:-Untitled}"
-DURATION="${DURATION:-0}"
-BODY="${BODY:-(no output)}"
-
-PAYLOAD=$(node -e "
-process.stdout.write(JSON.stringify({
- msg_type: 'interactive',
- card: {
- header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
- elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
- }
-}))
-")
-
-curl -s -X POST "$WEBHOOK_URL" \
- -H "Content-Type: application/json" \
- -d "$PAYLOAD"
-```
-
-Replace `WEBHOOK_URL` with your Feishu bot webhook URL. See the table above for all available variables. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format.
+> For detailed configuration examples (Slack, Feishu, terminal notifications, system notifications, etc.), see [notify_en.md](notify_en.md).
#### `webSearchTool` — Custom Web Search
diff --git a/docs/notify.md b/docs/notify.md
new file mode 100644
index 0000000..d73eef4
--- /dev/null
+++ b/docs/notify.md
@@ -0,0 +1,211 @@
+# Deep Code 任务完成通知
+
+当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。
+
+## 工作原理
+
+在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。
+
+## 注入的环境变量
+
+| 环境变量 | 说明 |
+|----------|------|
+| `DURATION` | 会话耗时,单位秒(整数) |
+| `STATUS` | 会话状态:`"completed"` 或 `"failed"` |
+| `FAIL_REASON` | 失败原因(仅失败时设置) |
+| `BODY` | 最后一条 AI 助手回复的文本内容 |
+| `TITLE` | 会话标题(对应 resume 列表中的标题) |
+
+## 配置方法
+
+编辑 `~/.deepcode/settings.json`,添加 `notify` 字段:
+
+```json
+{
+ "env": {
+ "MODEL": "deepseek-v4-pro",
+ "BASE_URL": "https://api.deepseek.com",
+ "API_KEY": "sk-..."
+ },
+ "thinkingEnabled": true,
+ "reasoningEffort": "max",
+ "notify": "/path/to/your-notify-script.sh"
+}
+```
+
+你也可以在 `env` 中配置通知脚本所需的自定义环境变量,例如 Slack Webhook URL:
+
+```json
+{
+ "env": {
+ "MODEL": "deepseek-v4-pro",
+ "BASE_URL": "https://api.deepseek.com",
+ "API_KEY": "sk-...",
+ "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********"
+ },
+ "notify": "/Users/you/.deepcode/notify-slack.sh"
+}
+```
+
+这些 `env` 中的变量会被注入到脚本的执行环境中。
+
+## Slack 通知
+
+### 1. 获取 Slack Webhook URL
+
+1. 创建 [Slack App](https://api.slack.com/apps)
+2. 在 App 页面点击 **Incoming Webhooks** → **Add New Webhook to Workspace**,生成 Webhook URL
+
+### 2. 创建通知脚本
+
+创建 `~/.deepcode/notify-slack.sh`:
+
+```bash
+#!/usr/bin/env bash
+SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
+CURRENT_DIR=$(pwd)
+BRANCH=$(git branch --show-current 2>/dev/null)
+curl -X POST "$SLACK_WEBHOOK_URL" \
+ -H "Content-type: application/json" \
+ --data "{
+ \"text\": \"✅ Deep Code 任务已完成\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION 秒\"
+ }"
+```
+
+给脚本添加可执行权限:
+
+```bash
+chmod +x ~/.deepcode/notify-slack.sh
+```
+
+### 3. 配置 settings.json
+
+```json
+{
+ "env": {
+ "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********"
+ },
+ "notify": "/Users/you/.deepcode/notify-slack.sh"
+}
+```
+
+> Python 版本的脚本同样支持,你可以在 `env` 中传入并引用任意自定义环境变量。
+
+## 飞书 / 企业微信等 Webhook 通知
+
+以下示例使用 `node` 构建 JSON(自动转义特殊字符),`curl` 发送。通过 `env` 传入 `WEBHOOK_URL`:
+
+```bash
+#!/bin/bash
+WEBHOOK_URL="${WEBHOOK_URL:-}"
+
+STATUS="${STATUS:-completed}"
+TITLE="${TITLE:-Untitled}"
+DURATION="${DURATION:-0}"
+BODY="${BODY:-(no output)}"
+
+PAYLOAD=$(node -e "
+process.stdout.write(JSON.stringify({
+ msg_type: 'interactive',
+ card: {
+ header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
+ elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
+ }
+}))
+")
+
+curl -s -X POST "$WEBHOOK_URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
+```
+
+```json
+{
+ "env": {
+ "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
+ },
+ "notify": "/Users/you/.deepcode/notify-feishu.sh"
+}
+```
+
+将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。此模式同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。
+
+## 终端通知(iTerm2 / Windows Terminal)
+
+如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。
+
+创建 `~/.deepcode/notify.sh`:
+
+```bash
+#!/bin/bash
+# iTerm2 / Windows Terminal OSC 9 通知
+printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本:
+
+```batch
+@echo off
+REM Windows Terminal OSC 9 通知
+echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
+```
+
+## macOS 系统通知
+
+```bash
+#!/bin/bash
+# macOS 系统通知
+osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+## Linux 系统通知
+
+需要安装 `libnotify-bin`:
+
+```bash
+sudo apt install libnotify-bin # Debian/Ubuntu
+```
+
+创建 `~/.deepcode/notify.sh`:
+
+```bash
+#!/bin/bash
+# Linux notify-send 通知
+notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s"
+```
+
+```json
+{
+ "notify": "/home/you/.deepcode/notify.sh"
+}
+```
+
+## Windows msg 弹窗通知
+
+```batch
+@echo off
+REM Windows msg 弹窗通知
+msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
+```
+
+```json
+{
+ "notify": "C:\\Users\\you\\.deepcode\\notify.bat"
+}
+```
+
+## 自定义通知脚本
+
+你可以根据通知脚本注入的环境变量自行编写任意逻辑的通知脚本(Python、Node.js、Ruby 等均可),只要脚本可执行即可。脚本中可通过 `env` 字段传入额外需要的配置变量。
diff --git a/docs/notify_en.md b/docs/notify_en.md
new file mode 100644
index 0000000..b949161
--- /dev/null
+++ b/docs/notify_en.md
@@ -0,0 +1,211 @@
+# Deep Code Task Completion Notification
+
+When the AI assistant finishes a round of tasks, Deep Code can automatically execute a notification script to send task results to your chosen channel (Slack, system notifications, etc.).
+
+## How It Works
+
+Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables.
+
+## Injected Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `DURATION` | Session duration in seconds (integer) |
+| `STATUS` | Session status: `"completed"` or `"failed"` |
+| `FAIL_REASON` | Failure reason (only set on failure) |
+| `BODY` | The text content of the last AI assistant reply |
+| `TITLE` | Session title (matches the resume list title) |
+
+## Configuration
+
+Edit `~/.deepcode/settings.json` and add the `notify` field:
+
+```json
+{
+ "env": {
+ "MODEL": "deepseek-v4-pro",
+ "BASE_URL": "https://api.deepseek.com",
+ "API_KEY": "sk-..."
+ },
+ "thinkingEnabled": true,
+ "reasoningEffort": "max",
+ "notify": "/path/to/your-notify-script.sh"
+}
+```
+
+You can also configure custom environment variables for the notify script in `env`, such as a Slack Webhook URL:
+
+```json
+{
+ "env": {
+ "MODEL": "deepseek-v4-pro",
+ "BASE_URL": "https://api.deepseek.com",
+ "API_KEY": "sk-...",
+ "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********"
+ },
+ "notify": "/Users/you/.deepcode/notify-slack.sh"
+}
+```
+
+These `env` variables are injected into the script's execution environment.
+
+## Slack Notification
+
+### 1. Get a Slack Webhook URL
+
+1. Create a [Slack App](https://api.slack.com/apps)
+2. In the App page, go to **Incoming Webhooks** → **Add New Webhook to Workspace** to generate a Webhook URL
+
+### 2. Create the Notification Script
+
+Create `~/.deepcode/notify-slack.sh`:
+
+```bash
+#!/usr/bin/env bash
+SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
+CURRENT_DIR=$(pwd)
+BRANCH=$(git branch --show-current 2>/dev/null)
+curl -X POST "$SLACK_WEBHOOK_URL" \
+ -H "Content-type: application/json" \
+ --data "{
+ \"text\": \"✅ Deep Code task completed\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION s\"
+ }"
+```
+
+Make the script executable:
+
+```bash
+chmod +x ~/.deepcode/notify-slack.sh
+```
+
+### 3. Configure settings.json
+
+```json
+{
+ "env": {
+ "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********"
+ },
+ "notify": "/Users/you/.deepcode/notify-slack.sh"
+}
+```
+
+> A Python version is also supported; you can pass and reference any custom environment variables via `env`.
+
+## Feishu / WeCom Webhook Notification
+
+Use `node` to build JSON (auto-escapes special characters) and `curl` to send. Pass `WEBHOOK_URL` via `env`:
+
+```bash
+#!/bin/bash
+WEBHOOK_URL="${WEBHOOK_URL:-}"
+
+STATUS="${STATUS:-completed}"
+TITLE="${TITLE:-Untitled}"
+DURATION="${DURATION:-0}"
+BODY="${BODY:-(no output)}"
+
+PAYLOAD=$(node -e "
+process.stdout.write(JSON.stringify({
+ msg_type: 'interactive',
+ card: {
+ header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } },
+ elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }]
+ }
+}))
+")
+
+curl -s -X POST "$WEBHOOK_URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
+```
+
+```json
+{
+ "env": {
+ "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx"
+ },
+ "notify": "/Users/you/.deepcode/notify-feishu.sh"
+}
+```
+
+Replace `WEBHOOK_URL` with your Feishu bot webhook URL. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format.
+
+## Terminal Notification (iTerm2 / Windows Terminal)
+
+On iTerm2 or Windows Terminal, you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies.
+
+Create `~/.deepcode/notify.sh`:
+
+```bash
+#!/bin/bash
+# iTerm2 / Windows Terminal OSC 9 notification
+printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}"
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+Windows users on Git Bash can use the same script; alternatively, create a `.bat` script:
+
+```batch
+@echo off
+REM Windows Terminal OSC 9 notification
+echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07
+```
+
+## macOS System Notification
+
+```bash
+#!/bin/bash
+# macOS system notification
+osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
+```
+
+```json
+{
+ "notify": "/Users/you/.deepcode/notify.sh"
+}
+```
+
+## Linux System Notification
+
+Requires `libnotify-bin`:
+
+```bash
+sudo apt install libnotify-bin # Debian/Ubuntu
+```
+
+Create `~/.deepcode/notify.sh`:
+
+```bash
+#!/bin/bash
+# Linux notify-send notification
+notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s"
+```
+
+```json
+{
+ "notify": "/home/you/.deepcode/notify.sh"
+}
+```
+
+## Windows msg Popup Notification
+
+```batch
+@echo off
+REM Windows msg popup notification
+msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)"
+```
+
+```json
+{
+ "notify": "C:\\Users\\you\\.deepcode\\notify.bat"
+}
+```
+
+## Custom Notification Scripts
+
+You can write your own notification scripts in any language (Python, Node.js, Ruby, etc.) using the injected environment variables and any additional variables passed via `env`.
From 1bd7e6a38c3f0f58a9b6971ba31d8c888d9f975d Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 16:24:40 +0800
Subject: [PATCH 39/95] perf: reuse OpenAI client instance and add connection
warmup
Cache the OpenAI client at module level keyed by (apiKey, baseURL)
to avoid creating a fresh HTTP connection pool on every LLM turn.
The client is a stateless fetch wrapper so sharing across calls is
safe. Model, thinking-mode and other settings are still read fresh
from config files each time.
Also add a mount-time warmup effect that eagerly creates the client
so the TCP+TLS connection is established while the user composes
their first prompt.
---
.gitignore | 1 +
src/ui/App.tsx | 41 +++++++++++++++++++++++++++++++++++++++--
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 11b67ce..dd972a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ dist/
.vscode/
*.tgz
*.log
+scripts/
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 582abaf..e82e5f3 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -162,6 +162,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
void refreshSkills();
}, [refreshSessionsList, refreshSkills]);
+ // Eagerly create the OpenAI client on mount so the TCP+TLS connection
+ // warmup (fire-and-forget inside createOpenAIClient) starts before the
+ // user sends their first prompt.
+ useEffect(() => {
+ createOpenAIClient(projectRoot);
+ }, [projectRoot]);
+
useLayoutEffect(() => {
const settings = resolveCurrentSettings(projectRoot);
void sessionManager.initMcpServers(settings.mcpServers);
@@ -721,6 +728,13 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res
);
}
+// Module-level cache for the OpenAI client instance. The client itself is
+// a stateless fetch wrapper, so it is safe to share across calls as long as
+// the apiKey + baseURL stay the same. Model, thinking-mode and other
+// settings are always read fresh from the project / user config files.
+let _cachedOpenAI: OpenAI | null = null;
+let _cachedOpenAIKey = "";
+
export function createOpenAIClient(projectRoot: string = process.cwd()): {
client: OpenAI | null;
model: string;
@@ -749,12 +763,35 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
};
}
- const client = new OpenAI({
+ const cacheKey = `${settings.apiKey}::${settings.baseURL}`;
+ if (_cachedOpenAI && _cachedOpenAIKey === cacheKey) {
+ return {
+ client: _cachedOpenAI,
+ model: settings.model,
+ baseURL: settings.baseURL,
+ thinkingEnabled: settings.thinkingEnabled,
+ reasoningEffort: settings.reasoningEffort,
+ debugLogEnabled: settings.debugLogEnabled,
+ notify: settings.notify,
+ webSearchTool: settings.webSearchTool,
+ env: settings.env,
+ machineId: getMachineId(),
+ };
+ }
+
+ _cachedOpenAI = new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseURL || undefined,
});
+ _cachedOpenAIKey = cacheKey;
+
+ // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API
+ // server while the user is composing their first prompt. Errors are
+ // silently ignored — the real request will retry on its own if needed.
+ void _cachedOpenAI.models.list().catch(() => {});
+
return {
- client,
+ client: _cachedOpenAI,
model: settings.model,
baseURL: settings.baseURL,
thinkingEnabled: settings.thinkingEnabled,
From 2e5b2ed2a4eed8c463546385ecbf374002a8d6c6 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 16:54:30 +0800
Subject: [PATCH 40/95] perf: replace undici fetch with custom https.Agent for
long keepAlive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The default undici-based global fetch only keeps connections alive for
4 seconds, which is too short for a CLI where the user may spend
10–30 seconds reading output before typing the next prompt.
Add a custom fetch implementation backed by node:https.Agent with
keepAlive: true and a 60-second idle timeout. The custom fetch is
passed to the OpenAI SDK constructor so every LLM API request
benefits from persistent connections across conversational turns.
Also handles streaming request bodies (ReadableStream) for SDK
features like file uploads.
---
src/ui/App.tsx | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 95 insertions(+)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index e82e5f3..1cdd855 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink";
import chalk from "chalk";
import * as fs from "fs";
+import https from "node:https";
import * as os from "os";
import * as path from "path";
import OpenAI from "openai";
@@ -728,6 +729,99 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res
);
}
+// Custom fetch implementation that uses node:https.Agent with a configurable
+// keepAlive timeout. The default undici-based global fetch only keeps
+// connections alive for 4 seconds, which is too short for a CLI where the
+// user may spend 10–30 seconds reading output before typing the next prompt.
+// With this custom Agent we get full control over idle connection lifetime.
+const KEEP_ALIVE_MSEC = 60_000; // 1 minute
+
+function createCustomFetch(keepAliveMsecs: number = KEEP_ALIVE_MSEC) {
+ const agent = new https.Agent({ keepAlive: true, keepAliveMsecs });
+
+ return async function customFetch(url: string | URL | Request, init?: RequestInit): Promise {
+ const urlObj = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url);
+ const { method = "GET", headers = {}, body: reqBody, signal } = init ?? {};
+
+ // Normalize Headers to a plain Record
+ const plainHeaders: Record = {};
+ if (headers instanceof Headers) {
+ for (const [k, v] of headers) plainHeaders[k] = v;
+ } else if (Array.isArray(headers)) {
+ for (const [k, v] of headers) plainHeaders[k] = v;
+ } else {
+ Object.assign(plainHeaders, headers);
+ }
+
+ const port = urlObj.port ? Number(urlObj.port) : 443;
+
+ return new Promise((resolve, reject) => {
+ const req = https.request(
+ {
+ hostname: urlObj.hostname,
+ port,
+ path: urlObj.pathname + urlObj.search,
+ method,
+ headers: plainHeaders,
+ agent,
+ signal: signal ?? undefined,
+ },
+ (res) => {
+ const resHeaders = new Headers();
+ for (const [k, v] of Object.entries(res.headers)) {
+ if (v) (Array.isArray(v) ? v : [v]).forEach((val) => resHeaders.append(k, val));
+ }
+
+ const body = new ReadableStream({
+ start(controller) {
+ res.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
+ res.on("end", () => controller.close());
+ res.on("error", (err) => controller.error(err));
+ },
+ cancel() {
+ res.destroy();
+ },
+ });
+
+ resolve(
+ new Response(body, {
+ status: res.statusCode,
+ statusText: res.statusMessage,
+ headers: resHeaders,
+ })
+ );
+ }
+ );
+
+ req.on("error", reject);
+
+ if (reqBody) {
+ if (typeof reqBody === "string" || reqBody instanceof Uint8Array || ArrayBuffer.isView(reqBody)) {
+ req.write(reqBody as Parameters[0]);
+ } else if (reqBody instanceof ReadableStream) {
+ // Pipe streaming request body (used for file uploads by the SDK)
+ const reader = (reqBody as ReadableStream).getReader();
+ (async () => {
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) req.write(value);
+ }
+ req.end();
+ } catch (err) {
+ req.destroy(err instanceof Error ? err : new Error(String(err)));
+ }
+ })();
+ return; // req.end() is called inside the async IIFE
+ }
+ }
+
+ req.end();
+ });
+ };
+}
+
// Module-level cache for the OpenAI client instance. The client itself is
// a stateless fetch wrapper, so it is safe to share across calls as long as
// the apiKey + baseURL stay the same. Model, thinking-mode and other
@@ -782,6 +876,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
_cachedOpenAI = new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseURL || undefined,
+ fetch: createCustomFetch(),
});
_cachedOpenAIKey = cacheKey;
From 6f8d2e228d853f8014741c8108f6936e7d037c82 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 17:04:09 +0800
Subject: [PATCH 41/95] refactor: replace custom fetch wrapper with undici
Agent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Use npm undici's Agent with keepAliveTimeout: 60s instead of the
90-line custom https.Agent-based fetch wrapper. The approach is the
same but much simpler — just pass undiciFetch with a configured
Agent dispatcher to the OpenAI SDK.
---
src/ui/App.tsx | 103 +++++--------------------------------------------
1 file changed, 9 insertions(+), 94 deletions(-)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 1cdd855..42397a5 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink";
import chalk from "chalk";
import * as fs from "fs";
-import https from "node:https";
import * as os from "os";
import * as path from "path";
import OpenAI from "openai";
+import { Agent, fetch as undiciFetch } from "undici";
import {
type LlmStreamProgress,
type MessageMeta,
@@ -729,98 +729,12 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res
);
}
-// Custom fetch implementation that uses node:https.Agent with a configurable
-// keepAlive timeout. The default undici-based global fetch only keeps
-// connections alive for 4 seconds, which is too short for a CLI where the
-// user may spend 10–30 seconds reading output before typing the next prompt.
-// With this custom Agent we get full control over idle connection lifetime.
-const KEEP_ALIVE_MSEC = 60_000; // 1 minute
-
-function createCustomFetch(keepAliveMsecs: number = KEEP_ALIVE_MSEC) {
- const agent = new https.Agent({ keepAlive: true, keepAliveMsecs });
-
- return async function customFetch(url: string | URL | Request, init?: RequestInit): Promise {
- const urlObj = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url);
- const { method = "GET", headers = {}, body: reqBody, signal } = init ?? {};
-
- // Normalize Headers to a plain Record
- const plainHeaders: Record = {};
- if (headers instanceof Headers) {
- for (const [k, v] of headers) plainHeaders[k] = v;
- } else if (Array.isArray(headers)) {
- for (const [k, v] of headers) plainHeaders[k] = v;
- } else {
- Object.assign(plainHeaders, headers);
- }
-
- const port = urlObj.port ? Number(urlObj.port) : 443;
-
- return new Promise((resolve, reject) => {
- const req = https.request(
- {
- hostname: urlObj.hostname,
- port,
- path: urlObj.pathname + urlObj.search,
- method,
- headers: plainHeaders,
- agent,
- signal: signal ?? undefined,
- },
- (res) => {
- const resHeaders = new Headers();
- for (const [k, v] of Object.entries(res.headers)) {
- if (v) (Array.isArray(v) ? v : [v]).forEach((val) => resHeaders.append(k, val));
- }
-
- const body = new ReadableStream({
- start(controller) {
- res.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
- res.on("end", () => controller.close());
- res.on("error", (err) => controller.error(err));
- },
- cancel() {
- res.destroy();
- },
- });
-
- resolve(
- new Response(body, {
- status: res.statusCode,
- statusText: res.statusMessage,
- headers: resHeaders,
- })
- );
- }
- );
-
- req.on("error", reject);
-
- if (reqBody) {
- if (typeof reqBody === "string" || reqBody instanceof Uint8Array || ArrayBuffer.isView(reqBody)) {
- req.write(reqBody as Parameters[0]);
- } else if (reqBody instanceof ReadableStream) {
- // Pipe streaming request body (used for file uploads by the SDK)
- const reader = (reqBody as ReadableStream).getReader();
- (async () => {
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- if (value) req.write(value);
- }
- req.end();
- } catch (err) {
- req.destroy(err instanceof Error ? err : new Error(String(err)));
- }
- })();
- return; // req.end() is called inside the async IIFE
- }
- }
-
- req.end();
- });
- };
-}
+// Custom undici Agent with a 60-second keepAlive timeout. The default
+// global fetch (undici) only keeps connections alive for 4 seconds, which
+// is too short for a CLI where the user may spend 10–30 seconds reading
+// output between prompts. By passing a dedicated Agent to undiciFetch we
+// keep connections reusable for a full minute after the last request.
+const _keepAliveAgent = new Agent({ keepAliveTimeout: 60_000 });
// Module-level cache for the OpenAI client instance. The client itself is
// a stateless fetch wrapper, so it is safe to share across calls as long as
@@ -876,7 +790,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
_cachedOpenAI = new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseURL || undefined,
- fetch: createCustomFetch(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: _keepAliveAgent }),
});
_cachedOpenAIKey = cacheKey;
From 5d48d41b0c46813478f1b055671e9e32181c840f Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Tue, 19 May 2026 17:31:52 +0800
Subject: [PATCH 42/95] feat(bash): Add Bash timeout control feature and
related adjustments
---
src/common/bash-timeout.ts | 12 +++
src/session.ts | 138 +++++++++++++++++++++++++++++---
src/tests/session.test.ts | 34 ++++++++
src/tests/tool-handlers.test.ts | 54 ++++++++++++-
src/tools/bash-handler.ts | 106 ++++++++++++++++++++++--
src/tools/executor.ts | 17 ++++
src/ui/App.tsx | 10 ++-
src/ui/ProcessStdoutView.tsx | 111 +++++++++++++++++++++----
src/ui/PromptInput.tsx | 4 +-
9 files changed, 450 insertions(+), 36 deletions(-)
create mode 100644 src/common/bash-timeout.ts
diff --git a/src/common/bash-timeout.ts b/src/common/bash-timeout.ts
new file mode 100644
index 0000000..0a76d21
--- /dev/null
+++ b/src/common/bash-timeout.ts
@@ -0,0 +1,12 @@
+export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000;
+export const MIN_BASH_TIMEOUT_MS = 60 * 1000;
+export const BASH_TIMEOUT_INCREMENT_MS = 5 * 60 * 1000;
+export const BASH_TIMEOUT_DECREMENT_MS = 60 * 1000;
+
+export function clampBashTimeoutMs(timeoutMs: number, minTimeoutMs: number = MIN_BASH_TIMEOUT_MS): number {
+ if (!Number.isFinite(timeoutMs)) {
+ return DEFAULT_BASH_TIMEOUT_MS;
+ }
+ const minimum = Number.isFinite(minTimeoutMs) ? Math.max(1, Math.round(minTimeoutMs)) : MIN_BASH_TIMEOUT_MS;
+ return Math.max(minimum, Math.round(timeoutMs));
+}
diff --git a/src/session.ts b/src/session.ts
index 3b6b67a..10e0782 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -17,7 +17,12 @@ import {
getTools,
type ToolDefinition,
} from "./prompt";
-import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor";
+import {
+ ToolExecutor,
+ type CreateOpenAIClient,
+ type ProcessTimeoutControl,
+ type ProcessTimeoutInfo,
+} from "./tools/executor";
import { McpManager } from "./mcp/mcp-manager";
import type { McpServerConfig } from "./settings";
import { logApiError } from "./common/error-logger";
@@ -134,6 +139,21 @@ export type ModelUsage = {
total_reqs?: number;
};
+export type SessionProcessEntry = {
+ startTime: string;
+ command: string;
+ timeoutMs?: number;
+ deadlineAt?: string;
+ timedOut?: boolean;
+};
+
+export type BashTimeoutAdjustment = {
+ processId: string;
+ timeoutMs: number;
+ deadlineAt: string;
+ timedOut: boolean;
+};
+
export type SessionEntry = {
id: string;
summary: string | null;
@@ -148,7 +168,7 @@ export type SessionEntry = {
activeTokens: number;
createTime: string;
updateTime: string;
- processes: Map | null; // {pid: {startTime, command}}
+ processes: Map | null; // {pid: process info}
};
export type SessionsIndex = {
@@ -234,6 +254,7 @@ export class SessionManager {
private activeSessionId: string | null = null;
private activePromptController: AbortController | null = null;
private readonly sessionControllers = new Map();
+ private readonly processTimeoutControls = new Map();
private readonly toolExecutor: ToolExecutor;
private readonly mcpManager = new McpManager();
private mcpToolDefinitions: ToolDefinition[] = [];
@@ -1360,6 +1381,7 @@ ${skillMd}
const killedPids: number[] = [];
const failedPids: number[] = [];
for (const pid of processIds) {
+ this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid));
if (killProcessTree(pid, "SIGKILL")) {
killedPids.push(pid);
continue;
@@ -1397,6 +1419,37 @@ ${skillMd}
return !this.sessionControllers.has(sessionId);
}
+ adjustActiveBashTimeout(deltaMs: number): BashTimeoutAdjustment | null {
+ const sessionId = this.activeSessionId;
+ if (!sessionId || !Number.isFinite(deltaMs)) {
+ return null;
+ }
+ const session = this.getSession(sessionId);
+ if (!session?.processes) {
+ return null;
+ }
+
+ let selectedPid: string | null = null;
+ for (const pid of session.processes.keys()) {
+ if (this.processTimeoutControls.has(this.getProcessControlKey(sessionId, pid))) {
+ selectedPid = pid;
+ }
+ }
+ if (!selectedPid) {
+ return null;
+ }
+
+ const control = this.processTimeoutControls.get(this.getProcessControlKey(sessionId, selectedPid));
+ if (!control) {
+ return null;
+ }
+
+ const current = control.getInfo();
+ const next = control.setTimeoutMs(current.timeoutMs + deltaMs);
+ this.updateSessionProcessTimeout(sessionId, selectedPid, next);
+ return this.buildBashTimeoutAdjustment(selectedPid, next);
+ }
+
listSessions(): SessionEntry[] {
const index = this.loadSessionsIndex();
return index.entries;
@@ -1741,6 +1794,7 @@ ${skillMd}
onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command),
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
+ onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
shouldStop: () => this.isInterrupted(sessionId),
});
if (this.isInterrupted(sessionId)) {
@@ -2137,6 +2191,7 @@ ${skillMd}
private removeSessionProcess(sessionId: string, processId: string | number): void {
const now = new Date().toISOString();
+ this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));
this.updateSessionEntry(sessionId, (entry) => {
const processes = new Map(entry.processes ?? []);
processes.delete(String(processId));
@@ -2148,7 +2203,58 @@ ${skillMd}
});
}
- private getProcessIds(processes: Map | null): number[] {
+ private setSessionProcessTimeoutControl(
+ sessionId: string,
+ processId: string | number,
+ control: ProcessTimeoutControl | null
+ ): void {
+ const key = this.getProcessControlKey(sessionId, processId);
+ if (!control) {
+ this.processTimeoutControls.delete(key);
+ return;
+ }
+
+ this.processTimeoutControls.set(key, control);
+ this.updateSessionProcessTimeout(sessionId, processId, control.getInfo());
+ }
+
+ private updateSessionProcessTimeout(sessionId: string, processId: string | number, info: ProcessTimeoutInfo): void {
+ const now = new Date().toISOString();
+ this.updateSessionEntry(sessionId, (entry) => {
+ const processes = new Map(entry.processes ?? []);
+ const pid = String(processId);
+ const processInfo = processes.get(pid);
+ if (!processInfo) {
+ return entry;
+ }
+ processes.set(pid, {
+ ...processInfo,
+ timeoutMs: info.timeoutMs,
+ deadlineAt: new Date(info.deadlineAtMs).toISOString(),
+ timedOut: info.timedOut,
+ });
+ return {
+ ...entry,
+ processes,
+ updateTime: now,
+ };
+ });
+ }
+
+ private buildBashTimeoutAdjustment(processId: string, info: ProcessTimeoutInfo): BashTimeoutAdjustment {
+ return {
+ processId,
+ timeoutMs: info.timeoutMs,
+ deadlineAt: new Date(info.deadlineAtMs).toISOString(),
+ timedOut: info.timedOut,
+ };
+ }
+
+ private getProcessControlKey(sessionId: string, processId: string | number): string {
+ return `${sessionId}:${String(processId)}`;
+ }
+
+ private getProcessIds(processes: Map | null): number[] {
if (!processes) {
return [];
}
@@ -2232,11 +2338,11 @@ ${skillMd}
return usagePerModel;
}
- private deserializeProcesses(value: unknown): Map | null {
+ private deserializeProcesses(value: unknown): Map | null {
if (!value || typeof value !== "object") {
return null;
}
- const processes = new Map();
+ const processes = new Map();
for (const [pid, entry] of Object.entries(value as Record)) {
if (!pid) {
continue;
@@ -2245,22 +2351,34 @@ ${skillMd}
// Backward compatibility for old format where just stored start time
processes.set(pid, { startTime: entry, command: "Running process..." });
} else if (typeof entry === "object" && entry !== null) {
- const obj = entry as { startTime?: unknown; command?: unknown };
+ const obj = entry as {
+ startTime?: unknown;
+ command?: unknown;
+ timeoutMs?: unknown;
+ deadlineAt?: unknown;
+ timedOut?: unknown;
+ };
const startTime = typeof obj.startTime === "string" ? obj.startTime : new Date().toISOString();
const command = typeof obj.command === "string" ? obj.command : "Running process...";
- processes.set(pid, { startTime, command });
+ processes.set(pid, {
+ startTime,
+ command,
+ timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : undefined,
+ deadlineAt: typeof obj.deadlineAt === "string" ? obj.deadlineAt : undefined,
+ timedOut: typeof obj.timedOut === "boolean" ? obj.timedOut : undefined,
+ });
}
}
return processes.size > 0 ? processes : null;
}
private serializeProcesses(
- processes: Map | null
- ): Record | null {
+ processes: Map | null
+ ): Record | null {
if (!processes || processes.size === 0) {
return null;
}
- const serialized: Record = {};
+ const serialized: Record = {};
for (const [pid, entry] of processes.entries()) {
serialized[pid] = entry;
}
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index b7eadae..2ab4fe9 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -1641,6 +1641,40 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () =
assert.equal(session?.failReason, "interrupted");
});
+test("SessionManager adjusts the active Bash timeout control and session metadata", async () => {
+ const workspace = createTempDir("deepcode-bash-timeout-session-");
+ const manager = createSessionManager(workspace, "");
+ const sessionId = await manager.createSession({ text: "hello" });
+
+ (manager as any).addSessionProcess(sessionId, 123, "sleep 10");
+
+ let timeoutInfo = {
+ timeoutMs: 10 * 60 * 1000,
+ startedAtMs: 1000,
+ deadlineAtMs: 1000 + 10 * 60 * 1000,
+ timedOut: false,
+ };
+ (manager as any).setSessionProcessTimeoutControl(sessionId, 123, {
+ getInfo: () => timeoutInfo,
+ setTimeoutMs: (timeoutMs: number) => {
+ timeoutInfo = {
+ ...timeoutInfo,
+ timeoutMs,
+ deadlineAtMs: timeoutInfo.startedAtMs + timeoutMs,
+ };
+ return timeoutInfo;
+ },
+ });
+
+ const adjustment = manager.adjustActiveBashTimeout(5 * 60 * 1000);
+ const processInfo = manager.getSession(sessionId)?.processes?.get("123");
+
+ assert.equal(adjustment?.processId, "123");
+ assert.equal(adjustment?.timeoutMs, 15 * 60 * 1000);
+ assert.equal(processInfo?.timeoutMs, 15 * 60 * 1000);
+ assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString());
+});
+
function createSessionManager(projectRoot: string, machineId: string): SessionManager {
return new SessionManager({
projectRoot,
diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts
index 0b21edd..f66153c 100644
--- a/src/tests/tool-handlers.test.ts
+++ b/src/tests/tool-handlers.test.ts
@@ -4,7 +4,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { setTimeout as delay } from "node:timers/promises";
-import type { ToolExecutionContext } from "../tools/executor";
+import type { ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor";
import { handleBashTool } from "../tools/bash-handler";
import { handleEditTool } from "../tools/edit-handler";
import { handleReadTool } from "../tools/read-handler";
@@ -52,6 +52,58 @@ test("Bash streams stdout and stderr before command completion", async () => {
assert.match(streamedOutput, /err/);
});
+test("Bash terminates commands that exceed the configured timeout", async () => {
+ const workspace = createTempWorkspace();
+ const exitedPids: Array = [];
+
+ const result = await handleBashTool(
+ {
+ command: "printf 'start\\n'; sleep 5; printf 'done\\n'",
+ },
+ createContext("bash-timeout", workspace, {
+ bashTimeoutMs: 100,
+ bashMinTimeoutMs: 1,
+ onProcessExit: (pid) => {
+ exitedPids.push(pid);
+ },
+ })
+ );
+
+ assert.equal(result.ok, false);
+ assert.equal(result.error, "Command timed out.");
+ assert.equal(result.metadata?.timedOut, true);
+ assert.equal(result.metadata?.timeoutMs, 100);
+ assert.doesNotMatch(result.output ?? "", /done/);
+ assert.equal(exitedPids.length, 1);
+});
+
+test("Bash timeout control can extend the active command deadline", async () => {
+ const workspace = createTempWorkspace();
+ let timeoutControl: ProcessTimeoutControl | null = null;
+
+ const result = await handleBashTool(
+ {
+ command: "sleep 0.2; printf 'done\\n'",
+ },
+ createContext("bash-timeout-extend", workspace, {
+ bashTimeoutMs: 100,
+ bashMinTimeoutMs: 1,
+ onProcessTimeoutControl: (_pid, control) => {
+ if (control) {
+ timeoutControl = control;
+ control.setTimeoutMs(1000);
+ }
+ },
+ })
+ );
+
+ assert.ok(timeoutControl);
+ assert.equal(result.ok, true);
+ assert.match(result.output ?? "", /done/);
+ assert.equal(result.metadata?.timedOut, false);
+ assert.equal(result.metadata?.timeoutMs, 1000);
+});
+
test("UpdatePlan accepts a markdown task list string", async () => {
const workspace = createTempWorkspace();
const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n");
diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts
index 071da53..4272271 100644
--- a/src/tools/bash-handler.ts
+++ b/src/tools/bash-handler.ts
@@ -1,5 +1,7 @@
import { spawn } from "child_process";
-import type { ToolExecutionContext, ToolExecutionResult } from "./executor";
+import { DEFAULT_BASH_TIMEOUT_MS, clampBashTimeoutMs } from "../common/bash-timeout";
+import { killProcessTree } from "../common/process-tree";
+import type { ProcessTimeoutControl, ProcessTimeoutInfo, ToolExecutionContext, ToolExecutionResult } from "./executor";
import {
buildDisableExtglobCommand,
buildShellEnv,
@@ -22,6 +24,9 @@ type ToolCommandResult = {
truncated: boolean;
shellPath?: string;
startCwd?: string;
+ timedOut?: boolean;
+ timeoutMs?: number;
+ deadlineAt?: string;
};
export async function handleBashTool(
@@ -48,12 +53,15 @@ export async function handleBashTool(
execution.exitCode,
execution.signal,
shellPath,
- startCwd
+ startCwd,
+ execution.timedOut,
+ execution.timeoutMs,
+ execution.deadlineAtMs
);
updateSessionCwd(context.sessionId, startCwd, result.cwd);
if (execution.error || result.exitCode !== 0 || result.signal !== null) {
- const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error);
+ const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error, execution.timedOut);
return formatResult({ ...result, ok: false }, "bash", errorMessage);
}
@@ -102,10 +110,27 @@ async function executeShellCommand(
cwd: string,
command: string,
context: ToolExecutionContext
-): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> {
+): Promise<{
+ stdout: string;
+ stderr: string;
+ exitCode: number | null;
+ signal: string | null;
+ error?: string;
+ timedOut: boolean;
+ timeoutMs: number;
+ deadlineAtMs: number;
+}> {
return new Promise((resolve) => {
const detached = process.platform !== "win32";
const configuredEnv = context.createOpenAIClient?.().env ?? {};
+ const minTimeoutMs = context.bashMinTimeoutMs;
+ const initialTimeoutMs = clampBashTimeoutMs(context.bashTimeoutMs ?? DEFAULT_BASH_TIMEOUT_MS, minTimeoutMs);
+ const startedAtMs = Date.now();
+ let timeoutMs = initialTimeoutMs;
+ let deadlineAtMs = startedAtMs + timeoutMs;
+ let timedOut = false;
+ let settled = false;
+ let timeoutTimer: ReturnType | null = null;
const child = spawn(shellPath, shellArgs, {
cwd,
env: buildShellEnv(shellPath, configuredEnv),
@@ -114,8 +139,53 @@ async function executeShellCommand(
stdio: ["ignore", "pipe", "pipe"],
});
const pid = child.pid;
+
+ const getTimeoutInfo = (): ProcessTimeoutInfo => ({
+ timeoutMs,
+ startedAtMs,
+ deadlineAtMs,
+ timedOut,
+ });
+ const stopTimeoutTimer = () => {
+ if (timeoutTimer) {
+ clearTimeout(timeoutTimer);
+ timeoutTimer = null;
+ }
+ };
+ const triggerTimeout = () => {
+ if (settled || timedOut || typeof pid !== "number") {
+ return;
+ }
+ timedOut = true;
+ stopTimeoutTimer();
+ killProcessTree(pid, "SIGKILL");
+ };
+ const scheduleTimeout = () => {
+ stopTimeoutTimer();
+ if (settled) {
+ return;
+ }
+ const remainingMs = Math.max(0, deadlineAtMs - Date.now());
+ timeoutTimer = setTimeout(triggerTimeout, remainingMs);
+ };
+ const timeoutControl: ProcessTimeoutControl = {
+ getInfo: getTimeoutInfo,
+ setTimeoutMs: (nextTimeoutMs) => {
+ timeoutMs = clampBashTimeoutMs(nextTimeoutMs, minTimeoutMs);
+ deadlineAtMs = startedAtMs + timeoutMs;
+ if (deadlineAtMs <= Date.now()) {
+ triggerTimeout();
+ } else {
+ scheduleTimeout();
+ }
+ return getTimeoutInfo();
+ },
+ };
+
if (typeof pid === "number") {
context.onProcessStart?.(pid, command);
+ context.onProcessTimeoutControl?.(pid, timeoutControl);
+ scheduleTimeout();
}
let stdout = "";
@@ -138,7 +208,10 @@ async function executeShellCommand(
});
child.on("close", (code, signal) => {
+ settled = true;
+ stopTimeoutTimer();
if (typeof pid === "number") {
+ context.onProcessTimeoutControl?.(pid, null);
context.onProcessExit?.(pid);
}
resolve({
@@ -147,6 +220,9 @@ async function executeShellCommand(
exitCode: typeof code === "number" ? code : null,
signal: signal ?? null,
error,
+ timedOut,
+ timeoutMs,
+ deadlineAtMs,
});
});
});
@@ -173,7 +249,10 @@ function buildToolCommandResult(
exitCode: number | null,
signal: string | null,
shellPath: string,
- startCwd: string
+ startCwd: string,
+ timedOut: boolean = false,
+ timeoutMs?: number,
+ deadlineAtMs?: number
): ToolCommandResult {
const { output: cleanedStdout, cwd } = stripMarker(stdout, marker);
const combined = joinOutput(cleanedStdout, stderr);
@@ -187,6 +266,9 @@ function buildToolCommandResult(
truncated,
shellPath,
startCwd,
+ timedOut,
+ timeoutMs,
+ deadlineAt: typeof deadlineAtMs === "number" ? new Date(deadlineAtMs).toISOString() : undefined,
};
}
@@ -231,10 +313,13 @@ function truncateOutput(output: string): { text: string; truncated: boolean } {
return { text: output.slice(0, MAX_OUTPUT_CHARS), truncated: true };
}
-function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string): string {
+function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string, timedOut = false): string {
if (error) {
return error;
}
+ if (timedOut) {
+ return "Command timed out.";
+ }
if (signal) {
return `Command terminated by signal ${signal}.`;
}
@@ -253,6 +338,15 @@ function formatResult(result: ToolCommandResult, name: string, errorMessage?: st
shellPath: result.shellPath,
startCwd: result.startCwd,
};
+ if (typeof result.timedOut === "boolean") {
+ metadata.timedOut = result.timedOut;
+ }
+ if (typeof result.timeoutMs === "number") {
+ metadata.timeoutMs = result.timeoutMs;
+ }
+ if (result.deadlineAt) {
+ metadata.deadlineAt = result.deadlineAt;
+ }
const outputValue = result.output ? result.output : undefined;
diff --git a/src/tools/executor.ts b/src/tools/executor.ts
index 70ceab1..093e9f3 100644
--- a/src/tools/executor.ts
+++ b/src/tools/executor.ts
@@ -39,15 +39,31 @@ export type ToolExecutionContext = {
onProcessStart?: (processId: string | number, command: string) => void;
onProcessExit?: (processId: string | number) => void;
onProcessStdout?: (processId: string | number, chunk: string) => void;
+ onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void;
+ bashTimeoutMs?: number;
+ bashMinTimeoutMs?: number;
};
export type ToolExecutionHooks = {
onProcessStart?: (processId: string | number, command: string) => void;
onProcessExit?: (processId: string | number) => void;
onProcessStdout?: (processId: string | number, chunk: string) => void;
+ onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void;
shouldStop?: () => boolean;
};
+export type ProcessTimeoutInfo = {
+ timeoutMs: number;
+ startedAtMs: number;
+ deadlineAtMs: number;
+ timedOut: boolean;
+};
+
+export type ProcessTimeoutControl = {
+ getInfo: () => ProcessTimeoutInfo;
+ setTimeoutMs: (timeoutMs: number) => ProcessTimeoutInfo;
+};
+
export type ToolExecutionResult = {
ok: boolean;
name: string;
@@ -200,6 +216,7 @@ export class ToolExecutor {
onProcessStart: hooks?.onProcessStart,
onProcessExit: hooks?.onProcessExit,
onProcessStdout: hooks?.onProcessStdout,
+ onProcessTimeoutControl: hooks?.onProcessTimeoutControl,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 582abaf..c729dc0 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -54,7 +54,7 @@ type AppProps = {
export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement {
const { exit } = useApp();
const { stdout, write } = useStdout();
- const { columns } = useWindowSize();
+ const { columns, rows } = useWindowSize();
const { mode, setMode } = useRawModeContext();
const initialPromptSubmittedRef = useRef(false);
const processStdoutRef = useRef>(new Map());
@@ -281,6 +281,11 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
setShowProcessStdout(false);
}, []);
+ const handleAdjustBashTimeout = useCallback(
+ (deltaMs: number) => sessionManager.adjustActiveBashTimeout(deltaMs),
+ [sessionManager]
+ );
+
const handleModelConfigChange = useCallback(
(selection: ModelConfigSelection): string => {
const current = resolveCurrentSettings(projectRoot);
@@ -467,6 +472,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
}, [busy, mode, sessionManager, columns, stdout]);
const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]);
+ const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]);
const promptHistory = useMemo(() => {
return messages
.filter((message) => message.role === "user" && typeof message.content === "string")
@@ -568,7 +574,9 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
processStdoutRef={processStdoutRef}
runningProcesses={runningProcesses}
onDismiss={handleDismissProcessStdout}
+ onAdjustTimeout={handleAdjustBashTimeout}
screenWidth={screenWidth}
+ screenHeight={screenHeight}
/>
) : view === "session-list" ? (
>;
runningProcesses: RunningProcesses;
onDismiss: () => void;
+ onAdjustTimeout: (deltaMs: number) => BashTimeoutAdjustment | null;
screenWidth: number;
+ screenHeight: number;
};
const REFRESH_INTERVAL_MS = 150;
-const MAX_VISIBLE_LINES = 100;
+const MAX_PANEL_HEIGHT = 30;
+const MIN_PANEL_HEIGHT = 5;
export const ProcessStdoutView = React.memo(function ProcessStdoutView({
processStdoutRef,
runningProcesses,
onDismiss,
+ onAdjustTimeout,
screenWidth,
+ screenHeight,
}: ProcessStdoutViewProps): React.ReactElement {
const [stdoutText, setStdoutText] = useState("");
const [scrollOffset, setScrollOffset] = useState(0);
- const containerRef = useRef<{ lineCount: number }>({ lineCount: 0 });
+ const [statusMessage, setStatusMessage] = useState("");
+ const statusTimerRef = useRef | null>(null);
+
+ const panelHeight = Math.max(MIN_PANEL_HEIGHT, Math.min(screenHeight - 1, MAX_PANEL_HEIGHT));
+ const reservedRows = statusMessage ? 2 : 1;
+ const visibleLineLimit = Math.max(1, panelHeight - reservedRows);
useEffect(() => {
const updateStdout = () => {
@@ -51,21 +62,37 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({
return () => clearInterval(interval);
}, [processStdoutRef, runningProcesses]);
- // Update container line count for scroll awareness
+ useEffect(() => {
+ return () => {
+ if (statusTimerRef.current) {
+ clearTimeout(statusTimerRef.current);
+ }
+ };
+ }, []);
+
const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]);
- containerRef.current.lineCount = lines.length;
+ const timeoutProcess = useMemo(() => getLatestTimeoutProcess(runningProcesses), [runningProcesses]);
const visibleLines = useMemo(() => {
- if (lines.length <= MAX_VISIBLE_LINES) {
+ if (lines.length <= visibleLineLimit) {
return lines;
}
- const start = Math.max(0, lines.length - MAX_VISIBLE_LINES - scrollOffset);
- const slice = lines.slice(start, start + MAX_VISIBLE_LINES);
- if (lines.length > MAX_VISIBLE_LINES) {
+ const outputLineLimit = Math.max(1, visibleLineLimit - 1);
+ const start = Math.max(0, lines.length - outputLineLimit - scrollOffset);
+ const slice = lines.slice(start, start + outputLineLimit);
+ if (lines.length > visibleLineLimit) {
slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`);
}
return slice;
- }, [lines, scrollOffset]);
+ }, [lines, scrollOffset, visibleLineLimit]);
+
+ const setTemporaryStatus = (message: string) => {
+ setStatusMessage(message);
+ if (statusTimerRef.current) {
+ clearTimeout(statusTimerRef.current);
+ }
+ statusTimerRef.current = setTimeout(() => setStatusMessage(""), 2000);
+ };
useTerminalInput(
(input, key) => {
@@ -73,8 +100,18 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({
onDismiss();
return;
}
+ if (input === "+") {
+ const adjustment = onAdjustTimeout(BASH_TIMEOUT_INCREMENT_MS);
+ setTemporaryStatus(formatAdjustmentStatus(adjustment));
+ return;
+ }
+ if (input === "-") {
+ const adjustment = onAdjustTimeout(-BASH_TIMEOUT_DECREMENT_MS);
+ setTemporaryStatus(formatAdjustmentStatus(adjustment));
+ return;
+ }
if (key.upArrow) {
- setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - MAX_VISIBLE_LINES)));
+ setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - visibleLineLimit)));
return;
}
if (key.downArrow) {
@@ -82,11 +119,11 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({
return;
}
if (key.pageUp) {
- setScrollOffset((s) => Math.min(s + MAX_VISIBLE_LINES, Math.max(0, lines.length - MAX_VISIBLE_LINES)));
+ setScrollOffset((s) => Math.min(s + visibleLineLimit, Math.max(0, lines.length - visibleLineLimit)));
return;
}
if (key.pageDown) {
- setScrollOffset((s) => Math.max(s - MAX_VISIBLE_LINES, 0));
+ setScrollOffset((s) => Math.max(s - visibleLineLimit, 0));
return;
}
},
@@ -94,16 +131,58 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({
);
return (
-
+
📟 Process Output
- (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)
+ {` (${formatTimeoutHint(
+ timeoutProcess?.entry
+ )} · +/- adjust · Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)`}
-
+
{visibleLines.map((line, index) => (
{line}
))}
+ {statusMessage ? (
+
+ {statusMessage}
+
+ ) : null}
);
});
+
+function getLatestTimeoutProcess(
+ runningProcesses: RunningProcesses
+): { pid: string; entry: SessionProcessEntry } | null {
+ if (!runningProcesses) {
+ return null;
+ }
+ let latest: { pid: string; entry: SessionProcessEntry } | null = null;
+ for (const [pid, entry] of runningProcesses.entries()) {
+ if (typeof entry.timeoutMs !== "number") {
+ continue;
+ }
+ latest = { pid, entry };
+ }
+ return latest;
+}
+
+function formatTimeoutHint(entry?: SessionProcessEntry): string {
+ if (!entry || typeof entry.timeoutMs !== "number") {
+ return "timeout unavailable";
+ }
+ return `timeout ${formatDuration(entry.timeoutMs)}`;
+}
+
+function formatAdjustmentStatus(adjustment: BashTimeoutAdjustment | null): string {
+ if (!adjustment) {
+ return "No adjustable Bash timeout";
+ }
+ return `Timeout set to ${formatDuration(adjustment.timeoutMs)}`;
+}
+
+function formatDuration(ms: number): string {
+ const totalMinutes = Math.max(1, Math.round(ms / 60000));
+ return `${totalMinutes}m`;
+}
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index 1096a93..b35f72e 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -39,7 +39,7 @@ import {
} from "./fileMentions";
import type { FileMentionItem } from "./fileMentions";
import { readClipboardImageAsync } from "./clipboard";
-import type { SkillInfo } from "../session";
+import type { SessionEntry, SkillInfo } from "../session";
// Re-exported from prompt modules for backward compatibility
export { useTerminalInput, parseTerminalInput } from "./prompt";
@@ -70,7 +70,7 @@ type Props = {
loadingText?: string | null;
disabled?: boolean;
placeholder?: string;
- runningProcesses?: Map | null;
+ runningProcesses?: SessionEntry["processes"];
onSubmit: (submission: PromptSubmission) => void;
onModelConfigChange: (selection: ModelConfigSelection) => string | Promise;
onRawModeChange?: (mode: string) => void;
From c081efd169a7c2c47a503eb540b538640ac8810a Mon Sep 17 00:00:00 2001
From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com>
Date: Tue, 19 May 2026 18:16:19 +0800
Subject: [PATCH 43/95] Revert "fix: re-apply dynamic modifier parsing for
Shift+Enter after upstream sync"
This reverts commit 52dafba25903dc70258d7e59dbe86e283a0f091f.
---
src/tests/promptInputKeys.test.ts | 6 ++---
src/ui/prompt/cursor.ts | 4 +--
src/ui/prompt/useTerminalInput.ts | 43 +++----------------------------
3 files changed, 9 insertions(+), 44 deletions(-)
diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts
index 8952a3d..69d2075 100644
--- a/src/tests/promptInputKeys.test.ts
+++ b/src/tests/promptInputKeys.test.ts
@@ -80,7 +80,7 @@ test("parseTerminalInput keeps BS payload for meta+backspace", () => {
test("parseTerminalInput recognizes shifted return sequences", () => {
const { input, key } = parseTerminalInput("\u001B\r");
- assert.equal(input, "");
+ assert.equal(input, "\r");
assert.equal(key.return, true);
assert.equal(key.shift, true);
assert.equal(key.meta, false);
@@ -108,8 +108,8 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => {
});
test("terminal extended key helpers request and restore modifyOtherKeys mode", () => {
- assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m\u001B[>1u");
- assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m\u001B[4;1m");
+ assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m");
});
test("parseTerminalInput recognizes terminal focus events", () => {
diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts
index 59b24f2..2668470 100644
--- a/src/ui/prompt/cursor.ts
+++ b/src/ui/prompt/cursor.ts
@@ -41,11 +41,11 @@ function disableTerminalFocusReporting(): string {
}
export function enableTerminalExtendedKeys(): string {
- return "\u001B[>4;1m\u001B[>1u";
+ return "\u001B[>4;1m";
}
export function disableTerminalExtendedKeys(): string {
- return "\u001B[>4;0m\u001B[4;0m";
}
export function getPromptCursorPlacement(
diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts
index f448d4f..8013ff6 100644
--- a/src/ui/prompt/useTerminalInput.ts
+++ b/src/ui/prompt/useTerminalInput.ts
@@ -26,42 +26,7 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]);
const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]);
const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]);
const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]);
-const SHIFT_RETURN_SEQUENCES = new Set([
- "\u001B\r",
- "\u001B[13;2u",
- "\u001B[13;1u",
- "\u001B[13;2~",
- "\u001B[13;1~",
- "\u001B[27;2;13~",
- "\u001B[27;1;13~",
-]);
-
-const CSI_SHIFT_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/;
-const CSI_EXTENDED_SHIFT_RETURN_RE = /^\u001B\[27;(\d+);13~$/;
-
-function isShiftReturn(raw: string): boolean {
- if (SHIFT_RETURN_SEQUENCES.has(raw)) return true;
- let m: RegExpMatchArray | null;
- if ((m = raw.match(CSI_SHIFT_RETURN_RE)) !== null) {
- const mod = parseInt(m[1], 10);
- return (mod & 2) !== 0 || (mod & 1) !== 0;
- }
- if ((m = raw.match(CSI_EXTENDED_SHIFT_RETURN_RE)) !== null) {
- const mod = parseInt(m[1], 10);
- return (mod & 2) !== 0 || (mod & 1) !== 0;
- }
- return false;
-}
-
-const CSI_RETURN_RE = /^\u001B\[13;(\d+)[u~]$/;
-const CSI_EXTENDED_RETURN_RE = /^\u001B\[27;(\d+);13~$/;
-
-function isReturn(raw: string): boolean {
- if (raw === "\r") return true;
- if (SHIFT_RETURN_SEQUENCES.has(raw)) return true;
- if (META_RETURN_SEQUENCES.has(raw)) return true;
- return CSI_RETURN_RE.test(raw) || CSI_EXTENDED_RETURN_RE.test(raw);
-}
+const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]);
const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]);
const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]);
const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]);
@@ -148,10 +113,10 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
end: END_SEQUENCES.has(raw),
pageDown: raw === "\u001B[6~",
pageUp: raw === "\u001B[5~",
- return: isReturn(raw),
+ return: raw === "\r" || SHIFT_RETURN_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw),
escape: raw === "\u001B",
ctrl: CTRL_LEFT_SEQUENCES.has(raw) || CTRL_RIGHT_SEQUENCES.has(raw),
- shift: isShiftReturn(raw),
+ shift: SHIFT_RETURN_SEQUENCES.has(raw),
tab: raw === "\t" || raw === "\u001B[Z",
backspace: BACKSPACE_BYTES.has(raw),
delete: FORWARD_DELETE_SEQUENCES.has(raw),
@@ -197,7 +162,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
key.shift = true;
}
- if (key.tab || key.backspace || key.delete || key.return) {
+ if (key.tab || key.backspace || key.delete) {
input = "";
}
From 255226a3c9bdd7254dd8b5728a0e1bff7de28707 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 18:33:10 +0800
Subject: [PATCH 44/95] chore: add undici devDependency for custom keepAlive
Agent
Required by the custom fetch wrapper that replaces the default
4s keepAlive undici global dispatcher with a custom Agent (60s).
---
package-lock.json | 13 ++++++++++++-
package.json | 3 ++-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 800d75a..0b43587 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,7 +38,8 @@
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
- "typescript-eslint": "^8.59.2"
+ "typescript-eslint": "^8.59.2",
+ "undici": "^8.3.0"
},
"engines": {
"node": ">=22"
@@ -4096,6 +4097,16 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
+ "node_modules/undici": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-8.3.0.tgz",
+ "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
diff --git a/package.json b/package.json
index c438d68..b805d18 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
- "typescript-eslint": "^8.59.2"
+ "typescript-eslint": "^8.59.2",
+ "undici": "^8.3.0"
}
}
From 5b74c00db5bf16e1519c6aaafb233c4c2b78bf1a Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 18:33:59 +0800
Subject: [PATCH 45/95] fix: move undici from devDependencies to dependencies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
undici is imported at runtime in App.tsx for the custom keepAlive
Agent. When bundled with --packages=external, end users need the
package installed — it cannot be a devDependency.
---
package-lock.json | 5 ++---
package.json | 4 ++--
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 0b43587..7d68f74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"ink-gradient": "^4.0.0",
"openai": "^6.35.0",
"react": "^19.2.5",
+ "undici": "^8.3.0",
"zod": "^4.4.3"
},
"bin": {
@@ -38,8 +39,7 @@
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
- "typescript-eslint": "^8.59.2",
- "undici": "^8.3.0"
+ "typescript-eslint": "^8.59.2"
},
"engines": {
"node": ">=22"
@@ -4101,7 +4101,6 @@
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-8.3.0.tgz",
"integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
diff --git a/package.json b/package.json
index b805d18..6d58864 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"ink-gradient": "^4.0.0",
"openai": "^6.35.0",
"react": "^19.2.5",
+ "undici": "^8.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -65,7 +66,6 @@
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
- "typescript-eslint": "^8.59.2",
- "undici": "^8.3.0"
+ "typescript-eslint": "^8.59.2"
}
}
From db78e2b1756e2e9e2f9eea008e30e2f4638e0856 Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 18:47:23 +0800
Subject: [PATCH 46/95] fix: downgrade undici to v7 for Node 20 compatibility
undici v8 requires Node >=22, but the CI matrix includes Node 20
which the project intentionally supports. v7 works on >=20.18.1.
---
package-lock.json | 10 +++++-----
package.json | 2 +-
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 7d68f74..82db9af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
"ink-gradient": "^4.0.0",
"openai": "^6.35.0",
"react": "^19.2.5",
- "undici": "^8.3.0",
+ "undici": "^7.25.0",
"zod": "^4.4.3"
},
"bin": {
@@ -4098,12 +4098,12 @@
}
},
"node_modules/undici": {
- "version": "8.3.0",
- "resolved": "https://registry.npmmirror.com/undici/-/undici-8.3.0.tgz",
- "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==",
+ "version": "7.25.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT",
"engines": {
- "node": ">=22.19.0"
+ "node": ">=20.18.1"
}
},
"node_modules/undici-types": {
diff --git a/package.json b/package.json
index 6d58864..b2826c2 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"ink-gradient": "^4.0.0",
"openai": "^6.35.0",
"react": "^19.2.5",
- "undici": "^8.3.0",
+ "undici": "^7.25.0",
"zod": "^4.4.3"
},
"devDependencies": {
From 87d52ade53833a18118e23a595f511a4823b0a6c Mon Sep 17 00:00:00 2001
From: lellansin
Date: Tue, 19 May 2026 19:00:28 +0800
Subject: [PATCH 47/95] fix: add 3s timeout to warmup request to prevent exit
hang
Codex review found that the fire-and-forget warmup models.list()
had no timeout. The OpenAI client defaults to a 10-minute timeout,
so an unreachable API could keep the Node process alive long after
the user exits.
---
src/ui/App.tsx | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 42397a5..515d5e6 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -796,9 +796,17 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
_cachedOpenAIKey = cacheKey;
// Fire-and-forget warmup: pre-establish TCP+TLS connection to the API
- // server while the user is composing their first prompt. Errors are
- // silently ignored — the real request will retry on its own if needed.
- void _cachedOpenAI.models.list().catch(() => {});
+ // server while the user is composing their first prompt. Bounded by a
+ // short timeout so a slow / unreachable API never blocks process exit.
+ void (async () => {
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), 3000);
+ try {
+ await _cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {});
+ } finally {
+ clearTimeout(timer);
+ }
+ })();
return {
client: _cachedOpenAI,
From 38246a0192a6c20f7eec6e1d6d904cd5ab335925 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Tue, 19 May 2026 19:40:37 +0800
Subject: [PATCH 48/95] 0.1.23
---
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 800d75a..17a77ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@vegamo/deepcode-cli",
- "version": "0.1.22",
+ "version": "0.1.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vegamo/deepcode-cli",
- "version": "0.1.22",
+ "version": "0.1.23",
"license": "MIT",
"dependencies": {
"chalk": "^5.6.2",
diff --git a/package.json b/package.json
index c438d68..b72fd96 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@vegamo/deepcode-cli",
- "version": "0.1.22",
+ "version": "0.1.23",
"description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal",
"license": "MIT",
"type": "module",
From f28cbce383fe342e4815f54db084ffec359a8006 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 09:02:30 +0800
Subject: [PATCH 49/95] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E6=AD=A3=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E8=B7=AF=E5=BE=84=E6=8B=BC=E5=86=99=E9=94=99=E8=AF=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将所有导入路径中的 "compoments" 修正为 "components"
- 更新多个文件中相关的导入语句,包括 App.tsx、index.ts、messageView.test.ts 和 PromptInput.tsx
- 保证组件引用路径正确,避免运行时找不到模块错误
- 提升代码的可维护性和一致性
---
src/tests/messageView.test.ts | 4 ++--
src/ui/App.tsx | 4 ++--
src/ui/PromptInput.tsx | 2 +-
src/ui/{compoments => components}/MessageView/index.tsx | 0
src/ui/{compoments => components}/MessageView/markdown.ts | 0
src/ui/{compoments => components}/MessageView/types.ts | 0
src/ui/{compoments => components}/MessageView/utils.ts | 0
.../{compoments => components}/RawModeExitPrompt/index.tsx | 0
.../{compoments => components}/RawModelDropdown/index.tsx | 0
src/ui/{compoments => components}/index.ts | 0
src/ui/index.ts | 6 +++---
11 files changed, 8 insertions(+), 8 deletions(-)
rename src/ui/{compoments => components}/MessageView/index.tsx (100%)
rename src/ui/{compoments => components}/MessageView/markdown.ts (100%)
rename src/ui/{compoments => components}/MessageView/types.ts (100%)
rename src/ui/{compoments => components}/MessageView/utils.ts (100%)
rename src/ui/{compoments => components}/RawModeExitPrompt/index.tsx (100%)
rename src/ui/{compoments => components}/RawModelDropdown/index.tsx (100%)
rename src/ui/{compoments => components}/index.ts (100%)
diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts
index 990c8ff..b806dbd 100644
--- a/src/tests/messageView.test.ts
+++ b/src/tests/messageView.test.ts
@@ -6,10 +6,10 @@ import {
renderMessageToStdout,
getUpdatePlanPreviewLines,
parseToolPayload,
-} from "../ui/compoments/MessageView/utils";
+} from "../ui/components/MessageView/utils";
import { RawMode } from "../ui/contexts";
import type { SessionMessage } from "../session";
-import type { ToolSummary } from "../ui/compoments/MessageView/types";
+import type { ToolSummary } from "../ui/components/MessageView/types";
test("parseDiffPreview removes headers and classifies lines", () => {
const lines = parseDiffPreview(
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index c729dc0..8ba842a 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -23,7 +23,7 @@ import {
resolveSettingsSources,
} from "../settings";
import { PromptInput, type PromptSubmission } from "./PromptInput";
-import { MessageView, RawModeExitPrompt } from "./compoments";
+import { MessageView, RawModeExitPrompt } from "./components";
import { SessionList } from "./SessionList";
import { buildLoadingText } from "./loadingText";
import { findExpandedThinkingId } from "./thinkingState";
@@ -38,7 +38,7 @@ import {
} from "./askUserQuestion";
import { buildExitSummaryText } from "./exitSummary";
import { RawMode, useRawModeContext } from "./contexts";
-import { renderMessageToStdout } from "./compoments/MessageView/utils";
+import { renderMessageToStdout } from "./components/MessageView/utils";
const DEFAULT_MODEL = "deepseek-v4-pro";
const DEFAULT_BASE_URL = "https://api.deepseek.com";
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index b35f72e..b9b1f8e 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -51,7 +51,7 @@ import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusRepor
import SlashCommandMenu from "./SlashCommandMenu";
import type { ModelConfigSelection, ReasoningEffort } from "../settings";
import DropdownMenu from "./DropdownMenu";
-import { RawModelDropdown } from "./compoments";
+import { RawModelDropdown } from "./components";
export type PromptSubmission = {
text: string;
diff --git a/src/ui/compoments/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx
similarity index 100%
rename from src/ui/compoments/MessageView/index.tsx
rename to src/ui/components/MessageView/index.tsx
diff --git a/src/ui/compoments/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts
similarity index 100%
rename from src/ui/compoments/MessageView/markdown.ts
rename to src/ui/components/MessageView/markdown.ts
diff --git a/src/ui/compoments/MessageView/types.ts b/src/ui/components/MessageView/types.ts
similarity index 100%
rename from src/ui/compoments/MessageView/types.ts
rename to src/ui/components/MessageView/types.ts
diff --git a/src/ui/compoments/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts
similarity index 100%
rename from src/ui/compoments/MessageView/utils.ts
rename to src/ui/components/MessageView/utils.ts
diff --git a/src/ui/compoments/RawModeExitPrompt/index.tsx b/src/ui/components/RawModeExitPrompt/index.tsx
similarity index 100%
rename from src/ui/compoments/RawModeExitPrompt/index.tsx
rename to src/ui/components/RawModeExitPrompt/index.tsx
diff --git a/src/ui/compoments/RawModelDropdown/index.tsx b/src/ui/components/RawModelDropdown/index.tsx
similarity index 100%
rename from src/ui/compoments/RawModelDropdown/index.tsx
rename to src/ui/components/RawModelDropdown/index.tsx
diff --git a/src/ui/compoments/index.ts b/src/ui/components/index.ts
similarity index 100%
rename from src/ui/compoments/index.ts
rename to src/ui/components/index.ts
diff --git a/src/ui/index.ts b/src/ui/index.ts
index f2e698c..aa757f9 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -9,8 +9,8 @@ export {
} from "./App";
export { default as AppContainer } from "./AppContainer";
export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
-export { MessageView } from "./compoments";
-export { parseDiffPreview } from "./compoments/MessageView/utils";
+export { MessageView } from "./components";
+export { parseDiffPreview } from "./components/MessageView/utils";
export {
PromptInput,
IMAGE_ATTACHMENT_CLEAR_HINT,
@@ -48,7 +48,7 @@ export {
} from "./askUserQuestion";
export { readClipboardImage, type ClipboardImage } from "./clipboard";
export { buildLoadingText, type LoadingTextInput } from "./loadingText";
-export { renderMarkdown } from "./compoments/MessageView/markdown";
+export { renderMarkdown } from "./components/MessageView/markdown";
export {
EMPTY_BUFFER,
insertText,
From bbf810d1a55bfa2c45cdf576ed186f7d0c606715 Mon Sep 17 00:00:00 2001
From: Seunghoon Shin
Date: Wed, 20 May 2026 12:13:43 +0900
Subject: [PATCH 50/95] fix: resolve CJK composition bug on iOS terminals
(backspace packet splitting)
---
src/ui/prompt/useTerminalInput.ts | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts
index 8013ff6..8fe0d60 100644
--- a/src/ui/prompt/useTerminalInput.ts
+++ b/src/ui/prompt/useTerminalInput.ts
@@ -193,6 +193,30 @@ export function useTerminalInput(
return;
}
const handleData = (data: Buffer | string) => {
+ const raw = String(data);
+
+ // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.).
+ // iOS keyboards send composed characters as a single packet like:
+ // "가\x7f나" (character + backspace + new character)
+ // Without splitting, parseTerminalInput treats the whole packet as
+ // one input and drops the composition backspaces, corrupting the text.
+ if (raw.includes("\x7f") && raw.length > 1) {
+ const parts = raw.split("\x7f");
+ if (parts[0]) {
+ const { input, key } = parseTerminalInput(parts[0]);
+ handlerRef.current(input, key);
+ }
+ for (let i = 1; i < parts.length; i++) {
+ const bs = parseTerminalInput("\x7f");
+ handlerRef.current(bs.input, bs.key);
+ if (parts[i]) {
+ const { input, key } = parseTerminalInput(parts[i]);
+ handlerRef.current(input, key);
+ }
+ }
+ return;
+ }
+
const { input, key } = parseTerminalInput(data);
handlerRef.current(input, key);
};
From 4605be4e3ccffa9a48d9203a32a1f5db5f0a0516 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 11:18:23 +0800
Subject: [PATCH 51/95] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E5=92=8C=E6=8A=80=E8=83=BD=E9=80=89=E6=8B=A9=E4=B8=8B?=
=?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=E7=BB=84=E4=BB=B6=E5=B9=B6=E9=9B=86?=
=?UTF-8?q?=E6=88=90=E5=88=B0PromptInput?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 创建 ModelsDropdown 组件支持选择模型及思考模式
- 创建 SkillsDropdown 组件支持选择和切换技能
- 在 ui/components/index.ts 中导出新增组件
- 在 ui/index.ts 中导出 ModelsDropdown 相关辅助方法
- 在 PromptInput 组件中替换旧模型选择逻辑,改用新增下拉组件
- 优化 PromptInput 的快捷键处理,实现技能和模型菜单切换
- 移除 PromptInput 内部的模型选择状态及逻辑,简化代码结构
- 保持现有功能一致,增加用户界面交互的灵活性与可用性
---
src/ui/PromptInput.tsx | 231 +++------------------
src/ui/components/ModelsDropdown/index.tsx | 165 +++++++++++++++
src/ui/components/SkillsDropdown/index.tsx | 74 +++++++
src/ui/components/index.ts | 2 +
src/ui/index.ts | 10 +-
5 files changed, 275 insertions(+), 207 deletions(-)
create mode 100644 src/ui/components/ModelsDropdown/index.tsx
create mode 100644 src/ui/components/SkillsDropdown/index.tsx
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index b9b1f8e..a79fe30 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -49,9 +49,9 @@ import { useTerminalInput } from "./prompt";
import type { InputKey } from "./prompt";
import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
import SlashCommandMenu from "./SlashCommandMenu";
-import type { ModelConfigSelection, ReasoningEffort } from "../settings";
+import type { ModelConfigSelection } from "../settings";
import DropdownMenu from "./DropdownMenu";
-import { RawModelDropdown } from "./components";
+import { ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components";
export type PromptSubmission = {
text: string;
@@ -79,21 +79,6 @@ type Props = {
};
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
-export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const;
-
-type ThinkingModeOption = {
- label: string;
- thinkingEnabled: boolean;
- reasoningEffort?: ReasoningEffort;
-};
-
-export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [
- { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" },
- { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" },
- { label: "No thinking", thinkingEnabled: false },
-];
-
-type ModelDropdownStep = "model" | "thinking";
const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement {
const [spinnerIndex, setSpinnerIndex] = useState(0);
@@ -140,10 +125,7 @@ export const PromptInput = React.memo(function PromptInput({
const [menuIndex, setMenuIndex] = useState(0);
const [showSkillsDropdown, setShowSkillsDropdown] = useState(false);
const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false);
- const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0);
- const [modelDropdownStep, setModelDropdownStep] = useState(null);
- const [modelDropdownIndex, setModelDropdownIndex] = useState(0);
- const [pendingModel, setPendingModel] = useState(null);
+ const [showModelDropdown, setShowModelDropdown] = useState(false);
const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot));
const [fileMentionIndex, setFileMentionIndex] = useState(0);
const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null);
@@ -164,19 +146,19 @@ export const PromptInput = React.memo(function PromptInput({
);
const showFileMentionMenu =
!showSkillsDropdown &&
- !modelDropdownStep &&
+ !showModelDropdown &&
fileMentionToken !== null &&
fileMentionKey !== dismissedFileMentionKey;
const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]);
const slashToken = getCurrentSlashToken(buffer);
const slashMenu = React.useMemo(
() =>
- showSkillsDropdown || modelDropdownStep || showFileMentionMenu
+ showSkillsDropdown || showModelDropdown || showFileMentionMenu
? []
: slashToken
? filterSlashCommands(slashItems, slashToken)
: [],
- [showSkillsDropdown, modelDropdownStep, showFileMentionMenu, slashToken, slashItems]
+ [showSkillsDropdown, showModelDropdown, showFileMentionMenu, slashToken, slashItems]
);
const showMenu = slashMenu.length > 0;
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
@@ -241,23 +223,6 @@ export const PromptInput = React.memo(function PromptInput({
}
}, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]);
- useEffect(() => {
- if (skillsDropdownIndex >= skills.length) {
- setSkillsDropdownIndex(Math.max(0, skills.length - 1));
- }
- }, [skills.length, skillsDropdownIndex]);
-
- useEffect(() => {
- if (!modelDropdownStep) {
- return;
- }
- const optionCount =
- modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length;
- if (modelDropdownIndex >= optionCount) {
- setModelDropdownIndex(Math.max(0, optionCount - 1));
- }
- }, [modelDropdownIndex, modelDropdownStep]);
-
useEffect(() => {
if (!statusMessage) {
return;
@@ -287,14 +252,6 @@ export const PromptInput = React.memo(function PromptInput({
}
if (key.escape) {
- if (modelDropdownStep) {
- closeModelDropdown();
- return;
- }
- if (showSkillsDropdown) {
- setShowSkillsDropdown(false);
- return;
- }
if (showFileMentionMenu && fileMentionKey) {
setDismissedFileMentionKey(fileMentionKey);
return;
@@ -348,7 +305,7 @@ export const PromptInput = React.memo(function PromptInput({
setPendingExit(false);
}
- if (openRawModelDropdown) {
+ if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) {
return;
}
@@ -356,53 +313,6 @@ export const PromptInput = React.memo(function PromptInput({
exitHistoryBrowsing();
}
- if (showSkillsDropdown) {
- if (skills.length === 0) {
- setShowSkillsDropdown(false);
- } else {
- if (key.upArrow) {
- setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length);
- return;
- }
- if (key.downArrow) {
- setSkillsDropdownIndex((idx) => (idx + 1) % skills.length);
- return;
- }
- if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) {
- const skill = skills[skillsDropdownIndex];
- if (skill) {
- toggleSelectedSkill(skill);
- }
- return;
- }
- if (key.tab) {
- setShowSkillsDropdown(false);
- return;
- }
- }
- }
-
- if (modelDropdownStep) {
- const optionCount =
- modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length;
- if (key.upArrow) {
- setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount);
- return;
- }
- if (key.downArrow) {
- setModelDropdownIndex((idx) => (idx + 1) % optionCount);
- return;
- }
- if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) {
- selectModelDropdownItem();
- return;
- }
- if (key.tab) {
- closeModelDropdown();
- return;
- }
- }
-
if (key.ctrl && (input === "v" || input === "V")) {
setStatusMessage("Reading clipboard...");
readClipboardImageAsync()
@@ -722,7 +632,8 @@ export const PromptInput = React.memo(function PromptInput({
}
if (item.kind === "model") {
clearSlashToken();
- openModelDropdown();
+ setShowSkillsDropdown(false);
+ setShowModelDropdown(true);
return;
}
if (item.kind === "raw") {
@@ -828,63 +739,9 @@ export const PromptInput = React.memo(function PromptInput({
clearUndoRedoStacks();
}
- function openModelDropdown(): void {
- const currentModelIndex = MODEL_COMMAND_MODELS.findIndex((model) => model === modelConfig.model);
- setPendingModel(null);
- setModelDropdownStep("model");
- setModelDropdownIndex(currentModelIndex >= 0 ? currentModelIndex : 0);
- setShowSkillsDropdown(false);
- }
-
- function closeModelDropdown(): void {
- setModelDropdownStep(null);
- setPendingModel(null);
- }
-
- function selectModelDropdownItem(): void {
- if (modelDropdownStep === "model") {
- const model = MODEL_COMMAND_MODELS[modelDropdownIndex] ?? modelConfig.model;
- setPendingModel(model);
- setModelDropdownStep("thinking");
- setModelDropdownIndex(getThinkingOptionIndex(modelConfig));
- return;
- }
-
- const option = MODEL_COMMAND_THINKING_OPTIONS[modelDropdownIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0];
- const selection: ModelConfigSelection = {
- model: pendingModel ?? modelConfig.model,
- thinkingEnabled: option.thinkingEnabled,
- reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort,
- };
- closeModelDropdown();
- Promise.resolve(onModelConfigChange(selection))
- .then((message) => {
- if (message) {
- setStatusMessage(message);
- }
- })
- .catch((error) => {
- const message = error instanceof Error ? error.message : String(error);
- setStatusMessage(`Failed to update model settings: ${message}`);
- });
- }
-
- const modelDropdownItems =
- modelDropdownStep === "model"
- ? MODEL_COMMAND_MODELS.map((model) => ({
- label: model,
- selected: model === (pendingModel ?? modelConfig.model),
- description: model === modelConfig.model ? "current model" : "",
- }))
- : MODEL_COMMAND_THINKING_OPTIONS.map((option) => ({
- label: option.label,
- selected: getThinkingOptionIndex(modelConfig) === MODEL_COMMAND_THINKING_OPTIONS.indexOf(option),
- description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled",
- }));
-
const showFooterText = useMemo(
- () => showMenu || showSkillsDropdown || openRawModelDropdown || modelDropdownStep !== null || showFileMentionMenu,
- [showMenu, showSkillsDropdown, modelDropdownStep, openRawModelDropdown, showFileMentionMenu]
+ () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu,
+ [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu]
);
const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null;
@@ -925,44 +782,22 @@ export const PromptInput = React.memo(function PromptInput({
onSelect={(mode) => onRawModeChange?.(mode)}
screenWidth={screenWidth}
/>
- {showSkillsDropdown ? (
- ({
- key: skill.path || skill.name,
- label: skill.name,
- description: skill.path,
- selected: isSkillSelected(selectedSkills, skill),
- statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined,
- }))}
- activeIndex={skillsDropdownIndex}
- activeColor="#229ac3"
- maxVisible={6}
- />
- ) : null}
- {modelDropdownStep ? (
- ({
- key: item.label,
- label: item.label,
- description: item.description,
- selected: item.selected,
- }))}
- activeIndex={modelDropdownIndex}
- activeColor="#229ac3"
- maxVisible={6}
- />
- ) : null}
+
+ setShowModelDropdown(false)}
+ onModelConfigChange={onModelConfigChange}
+ onStatusMessage={setStatusMessage}
+ />
{showFileMentionMenu ? (
-): number {
- const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => {
- if (!config.thinkingEnabled) {
- return !option.thinkingEnabled;
- }
- return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort;
- });
- return index >= 0 ? index : 0;
-}
-
export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState {
let start = state.cursor;
while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) {
diff --git a/src/ui/components/ModelsDropdown/index.tsx b/src/ui/components/ModelsDropdown/index.tsx
new file mode 100644
index 0000000..bdd68ab
--- /dev/null
+++ b/src/ui/components/ModelsDropdown/index.tsx
@@ -0,0 +1,165 @@
+import React, { useEffect, useState } from "react";
+import { useInput } from "ink";
+import DropdownMenu from "../../DropdownMenu";
+import type { ModelConfigSelection, ReasoningEffort } from "../../../settings";
+
+type ModelStep = "model" | "thinking";
+
+type ThinkingModeOption = {
+ label: string;
+ thinkingEnabled: boolean;
+ reasoningEffort?: ReasoningEffort;
+};
+
+export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const;
+
+export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [
+ { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" },
+ { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" },
+ { label: "No thinking", thinkingEnabled: false },
+];
+
+function getThinkingOptionIndex(config: Pick): number {
+ const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => {
+ if (!config.thinkingEnabled) {
+ return !option.thinkingEnabled;
+ }
+ return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort;
+ });
+ return index >= 0 ? index : 0;
+}
+
+type Props = {
+ open: boolean;
+ modelConfig: ModelConfigSelection;
+ width: number;
+ onClose: () => void;
+ onModelConfigChange: (selection: ModelConfigSelection) => string | Promise;
+ onStatusMessage?: (message: string | null) => void;
+};
+
+const ModelsDropdown: React.FC = ({
+ open,
+ modelConfig,
+ width,
+ onClose,
+ onModelConfigChange,
+ onStatusMessage,
+}) => {
+ const [step, setStep] = useState(null);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [pendingModel, setPendingModel] = useState(null);
+
+ // Initialize state when opened
+ useEffect(() => {
+ if (open) {
+ const currentIndex = MODEL_COMMAND_MODELS.findIndex((m) => m === modelConfig.model);
+ setPendingModel(null);
+ setStep("model");
+ setActiveIndex(currentIndex >= 0 ? currentIndex : 0);
+ } else {
+ setStep(null);
+ }
+ }, [open, modelConfig.model]);
+
+ // Validate activeIndex bounds
+ useEffect(() => {
+ if (!step) {
+ return;
+ }
+ const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length;
+ if (activeIndex >= optionCount) {
+ setActiveIndex(Math.max(0, optionCount - 1));
+ }
+ }, [activeIndex, step]);
+
+ function selectItem(): void {
+ if (step === "model") {
+ const model = MODEL_COMMAND_MODELS[activeIndex] ?? modelConfig.model;
+ setPendingModel(model);
+ setStep("thinking");
+ setActiveIndex(getThinkingOptionIndex(modelConfig));
+ return;
+ }
+
+ const option = MODEL_COMMAND_THINKING_OPTIONS[activeIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]!;
+ const selection: ModelConfigSelection = {
+ model: pendingModel ?? modelConfig.model,
+ thinkingEnabled: option.thinkingEnabled,
+ reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort,
+ };
+ onClose();
+ Promise.resolve(onModelConfigChange(selection))
+ .then((message) => {
+ if (message) {
+ onStatusMessage?.(message);
+ }
+ })
+ .catch((error) => {
+ const msg = error instanceof Error ? error.message : String(error);
+ onStatusMessage?.(`Failed to update model settings: ${msg}`);
+ });
+ }
+
+ useInput(
+ (input, key) => {
+ if (!step) {
+ return;
+ }
+
+ const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length;
+
+ if (key.upArrow) {
+ setActiveIndex((idx) => (idx - 1 + optionCount) % optionCount);
+ return;
+ }
+ if (key.downArrow) {
+ setActiveIndex((idx) => (idx + 1) % optionCount);
+ return;
+ }
+ if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) {
+ selectItem();
+ return;
+ }
+ if (key.tab || key.escape) {
+ onClose();
+ return;
+ }
+ },
+ { isActive: open }
+ );
+
+ if (!open || !step) {
+ return null;
+ }
+
+ const items =
+ step === "model"
+ ? MODEL_COMMAND_MODELS.map((model) => ({
+ key: model,
+ label: model,
+ description: model === modelConfig.model ? "current model" : "",
+ selected: model === (pendingModel ?? modelConfig.model),
+ }))
+ : MODEL_COMMAND_THINKING_OPTIONS.map((option, i) => ({
+ key: option.label,
+ label: option.label,
+ description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled",
+ selected: getThinkingOptionIndex(modelConfig) === i,
+ }));
+
+ return (
+
+ );
+};
+
+export { getThinkingOptionIndex };
+export default ModelsDropdown;
diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx
new file mode 100644
index 0000000..545e2ab
--- /dev/null
+++ b/src/ui/components/SkillsDropdown/index.tsx
@@ -0,0 +1,74 @@
+import DropdownMenu from "../../DropdownMenu";
+import React, { useEffect, useState } from "react";
+import { isSkillSelected } from "../../PromptInput";
+import type { SkillInfo } from "../../../session";
+import { useInput } from "ink";
+
+const SkillsDropdown: React.FC<{
+ open: boolean;
+ onClose?: (value: boolean) => void;
+ width: number;
+ skills: SkillInfo[];
+ selectedSkills: SkillInfo[];
+ onSelect?: (skill: SkillInfo) => void;
+}> = ({ open, width, skills, selectedSkills, onSelect, onClose }) => {
+ const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0);
+ useInput(
+ (input, key) => {
+ if (key.upArrow) {
+ setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length);
+ return;
+ }
+ if (key.downArrow) {
+ setSkillsDropdownIndex((idx) => (idx + 1) % skills.length);
+ return;
+ }
+ if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) {
+ const skill = skills[skillsDropdownIndex];
+ if (skill) {
+ onSelect?.(skill);
+ }
+ return;
+ }
+ if (key.tab) {
+ onClose?.(false);
+ return;
+ }
+ if (key.escape) {
+ onClose?.(false);
+ }
+ },
+ { isActive: open }
+ );
+
+ useEffect(() => {
+ if (skillsDropdownIndex >= skills.length) {
+ setSkillsDropdownIndex(Math.max(0, skills.length - 1));
+ }
+ }, [skills.length, skillsDropdownIndex]);
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+ ({
+ key: skill.path || skill.name,
+ label: skill.name,
+ description: skill.path,
+ selected: isSkillSelected(selectedSkills, skill),
+ statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined,
+ }))}
+ activeIndex={skillsDropdownIndex}
+ activeColor="#229ac3"
+ maxVisible={6}
+ />
+ );
+};
+
+export default SkillsDropdown;
diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts
index 942d3ed..1d929f3 100644
--- a/src/ui/components/index.ts
+++ b/src/ui/components/index.ts
@@ -1,3 +1,5 @@
export { default as RawModelDropdown } from "./RawModelDropdown";
export { MessageView } from "./MessageView";
export { RawModeExitPrompt } from "./RawModeExitPrompt";
+export { default as SkillsDropdown } from "./SkillsDropdown";
+export { default as ModelsDropdown } from "./ModelsDropdown";
diff --git a/src/ui/index.ts b/src/ui/index.ts
index aa757f9..efb4edd 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -1,3 +1,9 @@
+import {
+ getThinkingOptionIndex,
+ MODEL_COMMAND_MODELS,
+ MODEL_COMMAND_THINKING_OPTIONS,
+} from "./components/ModelsDropdown";
+
export {
readSettings,
readProjectSettings,
@@ -24,14 +30,12 @@ export {
getPromptReturnKeyAction,
renderBufferWithCursor,
buildInitPromptSubmission,
- getThinkingOptionIndex,
- MODEL_COMMAND_MODELS,
- MODEL_COMMAND_THINKING_OPTIONS,
useTerminalInput,
parseTerminalInput,
type PromptSubmission,
type InputKey,
} from "./PromptInput";
+export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS };
export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor";
export { SessionList, formatSessionTitle } from "./SessionList";
export { ThemedGradient } from "./ThemedGradient";
From 02865704effba4ee50201fd6389f48744293e108 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 11:35:20 +0800
Subject: [PATCH 52/95] feat: add undo functionality and enhance session
management
---
src/session.ts | 302 ++++++++++++++++++++++++++++++
src/tests/promptInputKeys.test.ts | 28 ++-
src/tests/session.test.ts | 296 +++++++++++++++++++++++++++++
src/tests/slashCommands.test.ts | 20 +-
src/tools/edit-handler.ts | 2 +
src/tools/executor.ts | 6 +
src/tools/write-handler.ts | 2 +
src/ui/App.tsx | 106 ++++++++++-
src/ui/PromptInput.tsx | 36 +++-
src/ui/UndoSelector.tsx | 195 +++++++++++++++++++
src/ui/index.ts | 2 +
src/ui/slashCommands.ts | 7 +
12 files changed, 997 insertions(+), 5 deletions(-)
create mode 100644 src/ui/UndoSelector.tsx
diff --git a/src/session.ts b/src/session.ts
index 3f79481..6b2ceee 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -2,6 +2,7 @@ import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
+import * as childProcess from "child_process";
import { fileURLToPath } from "url";
import matter from "gray-matter";
import ejs from "ejs";
@@ -34,6 +35,8 @@ const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new";
const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000;
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
+const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint";
+const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost";
type ChatCompletionDebugOptions = {
enabled?: boolean;
@@ -202,6 +205,13 @@ export type SessionMessage = {
updateTime: string;
meta?: MessageMeta;
html?: string;
+ checkpointHash?: string;
+};
+
+export type UndoTarget = {
+ message: SessionMessage;
+ index: number;
+ canRestoreCode: boolean;
};
export type UserPromptContent = {
@@ -902,6 +912,7 @@ The candidate skills are as follows:\n\n`;
userPrompt.skills = await this.normalizeSkills(userPrompt.skills);
this.throwIfAborted(signal);
const sessionId = crypto.randomUUID();
+ this.ensureFileHistorySession(sessionId);
const now = new Date().toISOString();
const index = this.loadSessionsIndex();
const entry: SessionEntry = {
@@ -1022,6 +1033,7 @@ ${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);
@@ -1480,6 +1492,61 @@ ${skillMd}
return messages;
}
+ listUndoTargets(sessionId: string): UndoTarget[] {
+ return this.listSessionMessages(sessionId)
+ .map((message, index) => ({ message, index }))
+ .filter(({ message }) => this.isUndoTargetMessage(message))
+ .map(({ message, index }) => ({
+ message,
+ index,
+ canRestoreCode: Boolean(
+ message.checkpointHash && this.canRestoreCheckpointHash(sessionId, message.checkpointHash)
+ ),
+ }));
+ }
+
+ restoreSessionConversation(sessionId: string, messageId: string): SessionMessage[] {
+ const messages = this.listSessionMessages(sessionId);
+ const targetIndex = messages.findIndex((message) => message.id === messageId);
+ if (targetIndex === -1) {
+ throw new Error("Selected message was not found in this session.");
+ }
+
+ const keptMessages = messages.slice(0, targetIndex);
+ this.saveSessionMessages(sessionId, keptMessages);
+ const now = new Date().toISOString();
+ const latestAssistant = [...keptMessages].reverse().find((message) => message.role === "assistant");
+ const latestAssistantParams = latestAssistant?.messageParams as
+ | { tool_calls?: unknown[]; reasoning_content?: string }
+ | null
+ | undefined;
+
+ this.updateSessionEntry(sessionId, (entry) => ({
+ ...entry,
+ assistantReply: latestAssistant?.content ?? null,
+ assistantThinking:
+ typeof latestAssistantParams?.reasoning_content === "string" ? latestAssistantParams.reasoning_content : null,
+ assistantRefusal: null,
+ toolCalls: null,
+ status: "completed",
+ failReason: null,
+ processes: null,
+ updateTime: now,
+ }));
+ return keptMessages;
+ }
+
+ restoreSessionCode(sessionId: string, messageId: string): void {
+ const message = this.listSessionMessages(sessionId).find((item) => item.id === messageId);
+ if (!message) {
+ throw new Error("Selected message was not found in this session.");
+ }
+ if (!message.checkpointHash) {
+ throw new Error("Selected message has no code checkpoint.");
+ }
+ this.restoreCheckpointHash(sessionId, message.checkpointHash);
+ }
+
private normalizeSessionMessage(message: SessionMessage): SessionMessage {
if (message.role !== "tool") {
return message;
@@ -1518,6 +1585,238 @@ ${skillMd}
return { projectCode, projectDir, sessionsIndexPath };
}
+ private getFileHistoryGitDir(): string {
+ const { projectDir } = this.getProjectStorage();
+ return path.join(projectDir, "file-history", ".git");
+ }
+
+ private getSessionBranchRef(sessionId: string): string | null {
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
+ return null;
+ }
+ return `refs/heads/${sessionId}`;
+ }
+
+ private ensureFileHistorySession(sessionId: string): string | undefined {
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef) {
+ return undefined;
+ }
+
+ try {
+ const gitDir = this.getFileHistoryGitDir();
+ if (!fs.existsSync(gitDir)) {
+ fs.mkdirSync(path.dirname(gitDir), { recursive: true });
+ this.runFileHistoryGit(["init"], { includeWorkTree: true });
+ }
+
+ const current = this.getCurrentCheckpointHash(sessionId);
+ if (current) {
+ return current;
+ }
+
+ const emptyTree = this.runFileHistoryGit(["mktree"], { includeWorkTree: false, input: "" }).trim();
+ const commitHash = this.createFileHistoryCommit(emptyTree, null, "Initial checkpoint");
+ this.runFileHistoryGit(["update-ref", branchRef, commitHash], { includeWorkTree: false });
+ return commitHash;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private getCurrentCheckpointHash(sessionId: string): string | undefined {
+ const gitDir = this.getFileHistoryGitDir();
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef || !fs.existsSync(gitDir)) {
+ return undefined;
+ }
+
+ try {
+ const hash = this.runFileHistoryGit(["rev-parse", "--verify", `${branchRef}^{commit}`], {
+ includeWorkTree: false,
+ }).trim();
+ return this.isCommitHash(hash) ? hash : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void {
+ const previousHash = this.ensureFileHistorySession(sessionId);
+ if (!previousHash) {
+ return;
+ }
+ this.updateLatestUserCheckpointHash(sessionId, undefined, previousHash);
+ const nextHash = this.recordFileHistoryCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint");
+ if (nextHash && nextHash !== previousHash) {
+ this.updateLatestUserCheckpointHash(sessionId, previousHash, nextHash);
+ }
+ }
+
+ private recordFileMutationCheckpoint(sessionId: string, filePath: string): void {
+ this.ensureFileHistorySession(sessionId);
+ this.recordFileHistoryCheckpoint(sessionId, [filePath], "File mutation checkpoint");
+ }
+
+ private recordFileHistoryCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined {
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef) {
+ return undefined;
+ }
+
+ const relativePaths = filePaths
+ .map((filePath) => this.toProjectRelativeGitPath(filePath))
+ .filter((filePath): filePath is string => Boolean(filePath));
+ if (relativePaths.length === 0) {
+ return this.getCurrentCheckpointHash(sessionId);
+ }
+
+ try {
+ const parentHash = this.ensureFileHistorySession(sessionId);
+ if (!parentHash) {
+ return undefined;
+ }
+ this.runFileHistoryGit(["read-tree", "--reset", branchRef], { includeWorkTree: true });
+ this.runFileHistoryGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true });
+ const treeHash = this.runFileHistoryGit(["write-tree"], { includeWorkTree: false }).trim();
+ const parentTreeHash = this.runFileHistoryGit(["rev-parse", `${parentHash}^{tree}`], {
+ includeWorkTree: false,
+ }).trim();
+ if (treeHash === parentTreeHash) {
+ return parentHash;
+ }
+
+ const commitHash = this.createFileHistoryCommit(treeHash, parentHash, message);
+ this.runFileHistoryGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false });
+ return commitHash;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private updateLatestUserCheckpointHash(sessionId: string, previousHash: string | undefined, nextHash: string): void {
+ const messages = this.listSessionMessages(sessionId);
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (!message || !this.isUndoTargetMessage(message)) {
+ continue;
+ }
+ if (message.checkpointHash && message.checkpointHash !== previousHash) {
+ return;
+ }
+ messages[index] = {
+ ...message,
+ checkpointHash: nextHash,
+ updateTime: new Date().toISOString(),
+ };
+ this.saveSessionMessages(sessionId, messages);
+ return;
+ }
+ }
+
+ private createFileHistoryCommit(treeHash: string, parentHash: string | null, message: string): string {
+ const args = ["commit-tree", treeHash];
+ if (parentHash) {
+ args.push("-p", parentHash);
+ }
+ args.push("-m", message);
+ return this.runFileHistoryGit(args, {
+ includeWorkTree: false,
+ env: this.getFileHistoryGitEnv(),
+ }).trim();
+ }
+
+ private getFileHistoryGitEnv(): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME,
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL,
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME,
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL,
+ };
+ }
+
+ 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;
+ }
+ return relativePath.split(path.sep).join("/");
+ }
+
+ private canRestoreCheckpointHash(sessionId: string, checkpointHash: string): boolean {
+ if (!this.isCommitHash(checkpointHash)) {
+ return false;
+ }
+ if (!this.getSessionBranchRef(sessionId)) {
+ return false;
+ }
+ const gitDir = this.getFileHistoryGitDir();
+ if (!fs.existsSync(gitDir)) {
+ return false;
+ }
+
+ try {
+ this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ private restoreCheckpointHash(sessionId: string, checkpointHash: string): void {
+ if (!this.isCommitHash(checkpointHash)) {
+ throw new Error("Invalid checkpoint hash.");
+ }
+ const gitDir = this.getFileHistoryGitDir();
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef || !fs.existsSync(gitDir)) {
+ throw new Error("File history Git repository was not found for this project.");
+ }
+ this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
+
+ try {
+ this.runFileHistoryGit(["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.
+ }
+ this.runFileHistoryGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true });
+ this.runFileHistoryGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false });
+ }
+
+ private runFileHistoryGit(
+ args: string[],
+ options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv }
+ ): string {
+ const gitDir = this.getFileHistoryGitDir();
+ const gitArgs = [`--git-dir=${gitDir}`];
+ if (options.includeWorkTree) {
+ gitArgs.push(`--work-tree=${this.projectRoot}`);
+ }
+ gitArgs.push(...args);
+ const result = childProcess.spawnSync("git", gitArgs, {
+ encoding: "utf8",
+ input: options.input,
+ env: options.env,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ if (result.status !== 0) {
+ const detail = (result.stderr || result.stdout || "").trim();
+ throw new Error(detail || `git ${args.join(" ")} failed`);
+ }
+ return result.stdout ?? "";
+ }
+
+ private isCommitHash(value: string): boolean {
+ return /^[0-9a-f]{40}$/i.test(value);
+ }
+
+ private isUndoTargetMessage(message: SessionMessage): boolean {
+ return message.role === "user" && message.visible && !message.compacted;
+ }
+
private ensureProjectDir(): string {
const { projectDir } = this.getProjectStorage();
fs.mkdirSync(projectDir, { recursive: true });
@@ -1628,6 +1927,7 @@ ${skillMd}
visible: true,
createTime: now,
updateTime: now,
+ checkpointHash: this.getCurrentCheckpointHash(sessionId),
};
}
@@ -1795,6 +2095,8 @@ ${skillMd}
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
+ onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath),
+ onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath),
shouldStop: () => this.isInterrupted(sessionId),
});
if (this.isInterrupted(sessionId)) {
diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts
index 69d2075..54213a1 100644
--- a/src/tests/promptInputKeys.test.ts
+++ b/src/tests/promptInputKeys.test.ts
@@ -19,10 +19,11 @@ import {
toggleSkillSelection,
renderBufferWithCursor,
buildInitPromptSubmission,
+ buildPromptDraftFromSessionMessage,
disableTerminalExtendedKeys,
enableTerminalExtendedKeys,
} from "../ui";
-import type { SkillInfo } from "../session";
+import type { SessionMessage, SkillInfo } from "../session";
test("parseTerminalInput treats DEL bytes as backspace", () => {
const { input, key } = parseTerminalInput("\u007F");
@@ -112,6 +113,31 @@ test("terminal extended key helpers request and restore modifyOtherKeys mode", (
assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m");
});
+test("buildPromptDraftFromSessionMessage restores text and image urls", () => {
+ const message: SessionMessage = {
+ id: "user-with-images",
+ sessionId: "session-1",
+ role: "user",
+ content: "revise this prompt",
+ contentParams: [
+ { type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
+ { type: "text", text: "ignored" },
+ { type: "image_url", image_url: { url: "data:image/jpeg;base64,def" } },
+ ],
+ messageParams: null,
+ compacted: false,
+ visible: true,
+ createTime: "2026-01-01T00:00:00.000Z",
+ updateTime: "2026-01-01T00:00:00.000Z",
+ };
+
+ assert.deepEqual(buildPromptDraftFromSessionMessage(message, 7), {
+ nonce: 7,
+ text: "revise this prompt",
+ imageUrls: ["data:image/png;base64,abc", "data:image/jpeg;base64,def"],
+ });
+});
+
test("parseTerminalInput recognizes terminal focus events", () => {
const focusIn = parseTerminalInput("\u001B[I");
const focusOut = parseTerminalInput("\u001B[O");
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index bfe5bad..c02c0fa 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -1,5 +1,6 @@
import { afterEach, test } from "node:test";
import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
@@ -887,6 +888,219 @@ test("replySession continues without appending /continue as a user message", asy
assert.equal(fetchCalls.length, 0);
});
+test("replySession records the current file-history branch head as checkpointHash", async (t) => {
+ if (!hasGit()) {
+ t.skip("git is not available");
+ return;
+ }
+
+ const workspace = createTempDir("deepcode-checkpoint-hash-workspace-");
+ const home = createTempDir("deepcode-checkpoint-hash-home-");
+ setHomeDir(home);
+
+ const manager = createSessionManager(workspace, "machine-id-checkpoint-hash");
+ (manager as any).activateSession = async () => {};
+
+ const sessionId = await manager.createSession({ text: "first prompt" });
+ const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "note.txt": "checkpoint\n" });
+
+ await manager.replySession(sessionId, { text: "second prompt" });
+
+ const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user");
+ assert.equal(userMessages[userMessages.length - 1]?.checkpointHash, checkpointHash);
+});
+
+test("createSession initializes file-history repo and session branch", async (t) => {
+ if (!hasGit()) {
+ t.skip("git is not available");
+ return;
+ }
+
+ const workspace = createTempDir("deepcode-file-history-init-workspace-");
+ const home = createTempDir("deepcode-file-history-init-home-");
+ setHomeDir(home);
+
+ const manager = createSessionManager(workspace, "machine-id-file-history-init");
+ (manager as any).activateSession = async () => {};
+
+ const sessionId = await manager.createSession({ text: "first prompt" });
+ const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user");
+ const gitDir = path.join(
+ home,
+ ".deepcode",
+ "projects",
+ workspace.replace(/[\\/]/g, "-").replace(/:/g, ""),
+ "file-history",
+ ".git"
+ );
+
+ assert.ok(fs.existsSync(gitDir));
+ assert.ok(userMessage?.checkpointHash);
+ assert.equal(
+ runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `refs/heads/${sessionId}^{commit}`]).trim(),
+ userMessage.checkpointHash
+ );
+});
+
+test("Write tool advances file-history while preserving the user prompt checkpoint", async (t) => {
+ if (!hasGit()) {
+ t.skip("git is not available");
+ return;
+ }
+
+ const workspace = createTempDir("deepcode-write-checkpoint-workspace-");
+ const home = createTempDir("deepcode-write-checkpoint-home-");
+ setHomeDir(home);
+
+ const filePath = path.join(workspace, "index.html");
+ const manager = createMockedClientSessionManager(workspace, [
+ {
+ choices: [
+ {
+ message: {
+ content: "",
+ tool_calls: [
+ {
+ id: "call-write-index",
+ type: "function",
+ function: {
+ name: "write",
+ arguments: JSON.stringify({ file_path: filePath, content: "Hello
\n" }),
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ ]);
+
+ const sessionId = await manager.createSession({ text: "create an index page" });
+ const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user");
+ assert.ok(userMessage?.checkpointHash);
+ assert.equal(fs.existsSync(filePath), true);
+
+ manager.restoreSessionCode(sessionId, userMessage.id);
+
+ assert.equal(fs.existsSync(filePath), false);
+});
+
+test("missing git executable does not block sessions or Write tool calls", async () => {
+ const workspace = createTempDir("deepcode-no-git-write-workspace-");
+ const home = createTempDir("deepcode-no-git-write-home-");
+ setHomeDir(home);
+
+ const originalPath = process.env.PATH;
+ process.env.PATH = "";
+ try {
+ const filePath = path.join(workspace, "index.html");
+ const manager = createMockedClientSessionManager(workspace, [
+ {
+ choices: [
+ {
+ message: {
+ content: "",
+ tool_calls: [
+ {
+ id: "call-write-no-git",
+ type: "function",
+ function: {
+ name: "write",
+ arguments: JSON.stringify({ file_path: filePath, content: "No Git
\n" }),
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ ]);
+
+ const sessionId = await manager.createSession({ text: "create an index page" });
+ const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user");
+
+ assert.equal(fs.readFileSync(filePath, "utf8"), "No Git
\n");
+ assert.equal(userMessage?.checkpointHash, undefined);
+ assert.equal(manager.getSession(sessionId)?.status, "completed");
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ }
+});
+
+test("restoreSessionConversation truncates messages before the selected user prompt", async () => {
+ const workspace = createTempDir("deepcode-undo-conversation-workspace-");
+ const home = createTempDir("deepcode-undo-conversation-home-");
+ setHomeDir(home);
+
+ const manager = createSessionManager(workspace, "machine-id-undo-conversation");
+ (manager as any).activateSession = async () => {};
+
+ const sessionId = await manager.createSession({ text: "first prompt" });
+ const firstAssistant = (manager as any).buildAssistantMessage(
+ sessionId,
+ "first answer",
+ null,
+ null
+ ) as SessionMessage;
+ (manager as any).appendSessionMessage(sessionId, firstAssistant);
+ await manager.replySession(sessionId, { text: "second prompt" });
+ const secondUserMessage = manager
+ .listSessionMessages(sessionId)
+ .filter((message) => message.role === "user")
+ .at(-1);
+ assert.ok(secondUserMessage);
+ const secondAssistant = (manager as any).buildAssistantMessage(
+ sessionId,
+ "second answer",
+ null,
+ null
+ ) as SessionMessage;
+ (manager as any).appendSessionMessage(sessionId, secondAssistant);
+
+ manager.restoreSessionConversation(sessionId, secondUserMessage.id);
+
+ const contents = manager.listSessionMessages(sessionId).map((message) => message.content);
+ assert.ok(contents.includes("first prompt"));
+ assert.ok(contents.includes("first answer"));
+ assert.ok(!contents.includes("second prompt"));
+ assert.ok(!contents.includes("second answer"));
+ assert.equal(manager.getSession(sessionId)?.assistantReply, "first answer");
+});
+
+test("restoreSessionCode restores project files from the recorded Git checkpoint", async (t) => {
+ if (!hasGit()) {
+ t.skip("git is not available");
+ return;
+ }
+
+ const workspace = createTempDir("deepcode-undo-code-workspace-");
+ const home = createTempDir("deepcode-undo-code-home-");
+ setHomeDir(home);
+
+ const manager = createSessionManager(workspace, "machine-id-undo-code");
+ const sessionId = "session-code-restore";
+ const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "before\n" });
+ createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "after\n", "new.txt": "remove me\n" });
+ fs.writeFileSync(path.join(workspace, "tracked.txt"), "after\n", "utf8");
+ fs.writeFileSync(path.join(workspace, "new.txt"), "remove me\n", "utf8");
+
+ (manager as any).appendSessionMessage(sessionId, {
+ ...buildTestMessage("user-with-checkpoint", sessionId, "user", "restore here"),
+ checkpointHash,
+ });
+
+ manager.restoreSessionCode(sessionId, "user-with-checkpoint");
+
+ assert.equal(fs.readFileSync(path.join(workspace, "tracked.txt"), "utf8"), "before\n");
+ assert.equal(fs.existsSync(path.join(workspace, "new.txt")), false);
+});
+
test("replySession /continue runs trailing pending tool calls before requesting another response", async () => {
const workspace = createTempDir("deepcode-continue-tool-workspace-");
const home = createTempDir("deepcode-continue-tool-home-");
@@ -1737,6 +1951,88 @@ test("SessionManager adjusts the active Bash timeout control and session metadat
assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString());
});
+function hasGit(): boolean {
+ try {
+ execFileSync("git", ["--version"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function createFileHistoryCommit(
+ home: string,
+ workspace: string,
+ sessionId: string,
+ files: Record
+): string {
+ const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, "");
+ const gitDir = path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git");
+ const 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]);
+
+ 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");
+ }
+ 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]);
+ return commitHash;
+}
+
+function runFileHistoryGit(
+ gitDir: string,
+ workspace: string,
+ args: string[],
+ input = "",
+ env: NodeJS.ProcessEnv = process.env
+): string {
+ return execFileSync("git", [`--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], {
+ encoding: "utf8",
+ input,
+ env,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+}
+
+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,
diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts
index 34b48d0..30d77ee 100644
--- a/src/tests/slashCommands.test.ts
+++ b/src/tests/slashCommands.test.ts
@@ -19,7 +19,18 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
assert.equal(items[0].kind, "skill");
assert.equal(items[0].name, "skill-writer");
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
- assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "raw", "exit"]);
+ assert.deepEqual(builtinNames, [
+ "skills",
+ "model",
+ "new",
+ "init",
+ "resume",
+ "continue",
+ "undo",
+ "mcp",
+ "raw",
+ "exit",
+ ]);
});
test("filterSlashCommands matches partial prefixes", () => {
@@ -66,6 +77,13 @@ test("findExactSlashCommand returns built-in /continue", () => {
assert.equal(item?.kind, "continue");
});
+test("findExactSlashCommand returns built-in /undo", () => {
+ const items = buildSlashCommands(skills);
+ const item = findExactSlashCommand(items, "/undo");
+ assert.ok(item);
+ assert.equal(item?.kind, "undo");
+});
+
test("findExactSlashCommand returns built-in /skills", () => {
const items = buildSlashCommands(skills);
const item = findExactSlashCommand(items, "/skills");
diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts
index 29108e5..454a673 100644
--- a/src/tools/edit-handler.ts
+++ b/src/tools/edit-handler.ts
@@ -321,7 +321,9 @@ export async function handleEditTool(
const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll);
const diffPreview = buildDiffPreview(filePath, raw, updated);
+ context.onBeforeFileMutation?.(filePath);
writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings);
+ context.onAfterFileMutation?.(filePath);
const freshMetadata = readTextFileWithMetadata(filePath);
recordFileState(
context.sessionId,
diff --git a/src/tools/executor.ts b/src/tools/executor.ts
index 093e9f3..73e31f5 100644
--- a/src/tools/executor.ts
+++ b/src/tools/executor.ts
@@ -40,6 +40,8 @@ export type ToolExecutionContext = {
onProcessExit?: (processId: string | number) => void;
onProcessStdout?: (processId: string | number, chunk: string) => void;
onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void;
+ onBeforeFileMutation?: (filePath: string) => void;
+ onAfterFileMutation?: (filePath: string) => void;
bashTimeoutMs?: number;
bashMinTimeoutMs?: number;
};
@@ -49,6 +51,8 @@ export type ToolExecutionHooks = {
onProcessExit?: (processId: string | number) => void;
onProcessStdout?: (processId: string | number, chunk: string) => void;
onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void;
+ onBeforeFileMutation?: (filePath: string) => void;
+ onAfterFileMutation?: (filePath: string) => void;
shouldStop?: () => boolean;
};
@@ -217,6 +221,8 @@ export class ToolExecutor {
onProcessExit: hooks?.onProcessExit,
onProcessStdout: hooks?.onProcessStdout,
onProcessTimeoutControl: hooks?.onProcessTimeoutControl,
+ onBeforeFileMutation: hooks?.onBeforeFileMutation,
+ onAfterFileMutation: hooks?.onAfterFileMutation,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts
index 153c1c6..a4c81bf 100644
--- a/src/tools/write-handler.ts
+++ b/src/tools/write-handler.ts
@@ -97,7 +97,9 @@ export async function handleWriteTool(
const encoding = existingMetadata?.encoding ?? "utf8";
const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF");
const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent);
+ context.onBeforeFileMutation?.(filePath);
const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings);
+ context.onAfterFileMutation?.(filePath);
const freshMetadata = readTextFileWithMetadata(filePath);
recordFileState(
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index c729dc0..70f9755 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -13,6 +13,7 @@ import {
type SessionMessage,
type SessionStatus,
type SkillInfo,
+ type UndoTarget,
type UserPromptContent,
} from "../session";
import {
@@ -22,9 +23,10 @@ import {
type ResolvedDeepcodingSettings,
resolveSettingsSources,
} from "../settings";
-import { PromptInput, type PromptSubmission } from "./PromptInput";
+import { PromptInput, type PromptDraft, type PromptSubmission } from "./PromptInput";
import { MessageView, RawModeExitPrompt } from "./compoments";
import { SessionList } from "./SessionList";
+import { UndoSelector, type UndoRestoreMode } from "./UndoSelector";
import { buildLoadingText } from "./loadingText";
import { findExpandedThinkingId } from "./thinkingState";
import { WelcomeScreen } from "./WelcomeScreen";
@@ -43,7 +45,7 @@ import { renderMessageToStdout } from "./compoments/MessageView/utils";
const DEFAULT_MODEL = "deepseek-v4-pro";
const DEFAULT_BASE_URL = "https://api.deepseek.com";
-type View = "chat" | "session-list" | "mcp-status";
+type View = "chat" | "session-list" | "undo" | "mcp-status";
type AppProps = {
projectRoot: string;
@@ -67,6 +69,8 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
const [skills, setSkills] = useState([]);
const [messages, setMessages] = useState([]);
const [sessions, setSessions] = useState([]);
+ const [undoTargets, setUndoTargets] = useState([]);
+ const [promptDraft, setPromptDraft] = useState(null);
const [statusLine, setStatusLine] = useState("");
const [errorLine, setErrorLine] = useState(null);
const [streamProgress, setStreamProgress] = useState(null);
@@ -223,6 +227,17 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
setView("session-list");
return;
}
+ if (submission.command === "undo") {
+ const activeSessionId = sessionManager.getActiveSessionId();
+ if (!activeSessionId) {
+ setErrorLine("No active session to undo.");
+ return;
+ }
+ setShowWelcome(false);
+ setUndoTargets(sessionManager.listUndoTargets(activeSessionId));
+ setView("undo");
+ return;
+ }
if (submission.command === "mcp") {
setShowWelcome(false);
setMcpStatuses(sessionManager.getMcpStatus());
@@ -337,6 +352,20 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
[handlePrompt]
);
+ 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);
+ },
+ [sessionManager]
+ );
+
useEffect(() => {
if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) {
return;
@@ -376,6 +405,45 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
[sessionManager, refreshSkills]
);
+ const handleUndoRestore = useCallback(
+ async (target: UndoTarget, restoreMode: UndoRestoreMode): Promise => {
+ const sessionId = sessionManager.getActiveSessionId();
+ if (!sessionId) {
+ setErrorLine("No active session to undo.");
+ setView("chat");
+ setShowWelcome(true);
+ return;
+ }
+
+ const errors: string[] = [];
+ if (restoreMode === "code-and-conversation") {
+ try {
+ sessionManager.restoreSessionCode(sessionId, target.message.id);
+ } catch (error) {
+ errors.push(`Code restore failed: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ let conversationRestored = false;
+ try {
+ sessionManager.restoreSessionConversation(sessionId, target.message.id);
+ conversationRestored = true;
+ } catch (error) {
+ errors.push(`Conversation restore failed: ${error instanceof Error ? error.message : String(error)}`);
+ }
+
+ refreshSessionsList();
+ await refreshSkills(sessionId);
+ setView("chat");
+ setErrorLine(errors.length > 0 ? errors.join(" ") : null);
+ if (conversationRestored) {
+ setPromptDraft(buildPromptDraftFromSessionMessage(target.message, Date.now()));
+ }
+ reloadActiveSessionView(sessionId);
+ },
+ [reloadActiveSessionView, refreshSessionsList, refreshSkills, sessionManager]
+ );
+
const handleRawModeChange = useCallback(
(nextMode: string) => {
const activeSessionId = sessionManager.getActiveSessionId();
@@ -584,6 +652,15 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
onSelect={(id) => void handleSelectSession(id)}
onCancel={() => setView("chat")}
/>
+ ) : view === "undo" ? (
+ void handleUndoRestore(target, restoreMode)}
+ onCancel={() => {
+ setView("chat");
+ setShowWelcome(true);
+ }}
+ />
) : view === "mcp-status" ? (
setView("chat")} />
) : shouldShowQuestionPrompt && pendingQuestion && !busy ? (
@@ -602,6 +679,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
busy={busy}
loadingText={loadingText}
runningProcesses={runningProcesses}
+ promptDraft={promptDraft}
onSubmit={handleSubmit}
onModelConfigChange={handleModelConfigChange}
onRawModeChange={handleRawModeChange}
@@ -646,6 +724,30 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session
};
}
+export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft {
+ return {
+ nonce,
+ text: typeof message.content === "string" ? message.content : "",
+ imageUrls: extractImageUrlsFromContentParams(message.contentParams),
+ };
+}
+
+function extractImageUrlsFromContentParams(contentParams: unknown): string[] {
+ const params = Array.isArray(contentParams) ? contentParams : contentParams ? [contentParams] : [];
+ const imageUrls: string[] = [];
+ for (const param of params) {
+ if (!param || typeof param !== "object") {
+ continue;
+ }
+ const record = param as { type?: unknown; image_url?: { url?: unknown } };
+ const url = record.image_url?.url;
+ if (record.type === "image_url" && typeof url === "string" && url) {
+ imageUrls.push(url);
+ }
+ }
+ return imageUrls;
+}
+
function isCurrentSessionEmpty(sessionManager: SessionManager): boolean {
const activeSessionId = sessionManager.getActiveSessionId();
return !activeSessionId || !sessionManager.getSession(activeSessionId);
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index b35f72e..ffb614d 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -57,7 +57,13 @@ export type PromptSubmission = {
text: string;
imageUrls: string[];
selectedSkills?: SkillInfo[];
- command?: "new" | "resume" | "continue" | "mcp" | "exit";
+ command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit";
+};
+
+export type PromptDraft = {
+ nonce: number;
+ text: string;
+ imageUrls: string[];
};
type Props = {
@@ -71,6 +77,7 @@ type Props = {
disabled?: boolean;
placeholder?: string;
runningProcesses?: SessionEntry["processes"];
+ promptDraft?: PromptDraft | null;
onSubmit: (submission: PromptSubmission) => void;
onModelConfigChange: (selection: ModelConfigSelection) => string | Promise;
onRawModeChange?: (mode: string) => void;
@@ -124,6 +131,7 @@ export const PromptInput = React.memo(function PromptInput({
disabled,
placeholder,
runningProcesses,
+ promptDraft,
onSubmit,
onModelConfigChange,
onInterrupt,
@@ -154,6 +162,7 @@ export const PromptInput = React.memo(function PromptInput({
const undoRedoRef = React.useRef(createPromptUndoRedoState());
const wasBusyRef = React.useRef(busy);
const hadFileMentionTokenRef = React.useRef(false);
+ const appliedDraftNonceRef = React.useRef(null);
const fileMentionToken = getCurrentFileMentionToken(buffer);
const hasFileMentionToken = fileMentionToken !== null;
@@ -266,6 +275,22 @@ export const PromptInput = React.memo(function PromptInput({
return () => clearTimeout(timer);
}, [statusMessage]);
+ useEffect(() => {
+ if (!promptDraft || appliedDraftNonceRef.current === promptDraft.nonce) {
+ return;
+ }
+ appliedDraftNonceRef.current = promptDraft.nonce;
+ setBuffer({ text: promptDraft.text, cursor: promptDraft.text.length });
+ setImageUrls(promptDraft.imageUrls);
+ setSelectedSkills([]);
+ setShowSkillsDropdown(false);
+ setOpenRawModelDropdown(false);
+ setModelDropdownStep(null);
+ setHistoryCursor(-1);
+ setDraftBeforeHistory(null);
+ clearPromptUndoRedoState(undoRedoRef.current);
+ }, [promptDraft]);
+
useEffect(() => {
setHistoryCursor(-1);
setDraftBeforeHistory(null);
@@ -766,6 +791,15 @@ export const PromptInput = React.memo(function PromptInput({
setShowSkillsDropdown(false);
return;
}
+ if (item.kind === "undo") {
+ onSubmit({ text: "/undo", imageUrls: [], command: "undo" });
+ setBuffer(EMPTY_BUFFER);
+ clearUndoRedoStacks();
+ setImageUrls([]);
+ setSelectedSkills([]);
+ setShowSkillsDropdown(false);
+ return;
+ }
if (item.kind === "mcp") {
onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" });
setBuffer(EMPTY_BUFFER);
diff --git a/src/ui/UndoSelector.tsx b/src/ui/UndoSelector.tsx
new file mode 100644
index 0000000..fad3e17
--- /dev/null
+++ b/src/ui/UndoSelector.tsx
@@ -0,0 +1,195 @@
+import React, { useMemo, useState } from "react";
+import { Box, Text, useInput, useWindowSize } from "ink";
+import type { UndoTarget } from "../session";
+
+export type UndoRestoreMode = "code-and-conversation" | "conversation";
+
+type Props = {
+ targets: UndoTarget[];
+ onSelect: (target: UndoTarget, mode: UndoRestoreMode) => void;
+ onCancel: () => void;
+};
+
+type Phase = "message" | "mode";
+
+const MAX_VISIBLE_TARGETS = 7;
+
+export function UndoSelector({ targets, onSelect, onCancel }: Props): React.ReactElement {
+ const [phase, setPhase] = useState("message");
+ const [targetIndex, setTargetIndex] = useState(Math.max(0, targets.length - 1));
+ const [modeIndex, setModeIndex] = useState(0);
+ const { columns, rows } = useWindowSize();
+
+ const safeTargetIndex = useMemo(() => {
+ if (targets.length === 0) {
+ return 0;
+ }
+ return Math.max(0, Math.min(targetIndex, targets.length - 1));
+ }, [targetIndex, targets.length]);
+
+ const selectedTarget = targets[safeTargetIndex] ?? null;
+ const maxVisible = Math.max(1, Math.min(MAX_VISIBLE_TARGETS, rows - 8));
+ const scrollOffset = Math.max(0, Math.min(safeTargetIndex - Math.floor(maxVisible / 2), targets.length - maxVisible));
+ const visibleTargets = targets.slice(scrollOffset, scrollOffset + maxVisible);
+
+ useInput((input, key) => {
+ if (key.escape || (key.ctrl && (input === "c" || input === "C"))) {
+ if (phase === "mode") {
+ setPhase("message");
+ return;
+ }
+ onCancel();
+ return;
+ }
+
+ if (targets.length === 0) {
+ return;
+ }
+
+ if (phase === "message") {
+ if (key.upArrow) {
+ setTargetIndex((index) => Math.max(0, index - 1));
+ return;
+ }
+ if (key.downArrow) {
+ setTargetIndex((index) => Math.min(targets.length - 1, index + 1));
+ return;
+ }
+ if (key.home) {
+ setTargetIndex(0);
+ return;
+ }
+ if (key.end) {
+ setTargetIndex(targets.length - 1);
+ return;
+ }
+ if (key.return) {
+ setModeIndex(selectedTarget?.canRestoreCode ? 0 : 1);
+ setPhase("mode");
+ }
+ return;
+ }
+
+ if (key.upArrow || key.downArrow) {
+ setModeIndex((index) => (index === 0 ? 1 : 0));
+ return;
+ }
+ if (key.return && selectedTarget) {
+ onSelect(selectedTarget, modeIndex === 0 ? "code-and-conversation" : "conversation");
+ }
+ });
+
+ if (targets.length === 0) {
+ return (
+
+ Nothing to undo yet.
+ Press Esc to go back.
+
+ );
+ }
+
+ return (
+
+
+
+
+ Undo
+
+ restore to the point before a prompt
+
+ {phase === "message" ? (
+
+ {visibleTargets.map((target, visibleIndex) => {
+ const actualIndex = scrollOffset + visibleIndex;
+ const isActive = actualIndex === safeTargetIndex;
+ return (
+
+ {isActive ? "> " : " "}
+
+
+ {formatUndoMessage(target.message.content)}
+
+
+ {formatTimestamp(target.message.createTime)}
+ {target.canRestoreCode ? " · code checkpoint available" : " · conversation only"}
+
+
+
+ );
+ })}
+
+ ) : (
+
+ Selected prompt:
+ {formatUndoMessage(selectedTarget?.message.content ?? "")}
+
+
+ {modeIndex === 0 ? "> " : " "}Restore code and conversation
+
+
+ {" "}
+ {selectedTarget?.canRestoreCode
+ ? "Restore files from the recorded Git checkpoint, then fork the conversation."
+ : "No code checkpoint is recorded for this prompt."}
+
+
+ {modeIndex === 1 ? "> " : " "}Restore conversation
+
+ {" "}Fork the conversation without changing files.
+
+
+ )}
+
+
+ {phase === "message"
+ ? "↑/↓ navigate · Enter choose · Esc cancel"
+ : "↑/↓ choose restore mode · Enter restore · Esc back"}
+
+
+
+
+ );
+}
+
+function formatUndoMessage(content: unknown): string {
+ const text = typeof content === "string" && content.trim() ? content.trim() : "(empty message)";
+ const singleLine = text.replace(/\r?\n/g, " ").replace(/\s+/g, " ");
+ return singleLine.length > 90 ? `${singleLine.slice(0, 89)}…` : singleLine;
+}
+
+function formatTimestamp(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.valueOf())) {
+ return value;
+ }
+ return date.toLocaleString();
+}
diff --git a/src/ui/index.ts b/src/ui/index.ts
index f2e698c..656b582 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -6,6 +6,7 @@ export {
writeModelConfigSelection,
resolveCurrentSettings,
createOpenAIClient,
+ buildPromptDraftFromSessionMessage,
} from "./App";
export { default as AppContainer } from "./AppContainer";
export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
@@ -30,6 +31,7 @@ export {
useTerminalInput,
parseTerminalInput,
type PromptSubmission,
+ type PromptDraft,
type InputKey,
} from "./PromptInput";
export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor";
diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts
index 948a7ab..6d9b7cc 100644
--- a/src/ui/slashCommands.ts
+++ b/src/ui/slashCommands.ts
@@ -8,6 +8,7 @@ export type SlashCommandKind =
| "init"
| "resume"
| "continue"
+ | "undo"
| "mcp"
| "raw"
| "exit";
@@ -58,6 +59,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [
label: "/continue",
description: "Continue the active conversation or pick one to resume",
},
+ {
+ kind: "undo",
+ name: "undo",
+ label: "/undo",
+ description: "Restore code and/or conversation to a previous point",
+ },
{
kind: "mcp",
name: "mcp",
From 883f1fd051a648b67b457288580565b6f7c7aca0 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 11:46:52 +0800
Subject: [PATCH 53/95] refactor: extract the file-history checkpoint logic out
of SessionManager
---
src/common/file-history.ts | 194 ++++++++++++++++++++++++++++++++++++
src/session.ts | 196 +++----------------------------------
2 files changed, 209 insertions(+), 181 deletions(-)
create mode 100644 src/common/file-history.ts
diff --git a/src/common/file-history.ts b/src/common/file-history.ts
new file mode 100644
index 0000000..5194e6e
--- /dev/null
+++ b/src/common/file-history.ts
@@ -0,0 +1,194 @@
+import * as childProcess from "child_process";
+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";
+
+export class GitFileHistory {
+ constructor(
+ private readonly projectRoot: string,
+ private readonly gitDir: string
+ ) {}
+
+ ensureSession(sessionId: string): string | undefined {
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef) {
+ return undefined;
+ }
+
+ try {
+ if (!fs.existsSync(this.gitDir)) {
+ fs.mkdirSync(path.dirname(this.gitDir), { recursive: true });
+ this.runGit(["init"], { includeWorkTree: true });
+ }
+
+ const current = this.getCurrentCheckpointHash(sessionId);
+ if (current) {
+ 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 });
+ return commitHash;
+ } catch {
+ return undefined;
+ }
+ }
+
+ getCurrentCheckpointHash(sessionId: string): string | undefined {
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef || !fs.existsSync(this.gitDir)) {
+ return undefined;
+ }
+
+ try {
+ const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`], {
+ includeWorkTree: false,
+ }).trim();
+ return isCommitHash(hash) ? hash : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ recordCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined {
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef) {
+ return undefined;
+ }
+
+ const relativePaths = filePaths
+ .map((filePath) => this.toProjectRelativeGitPath(filePath))
+ .filter((filePath): filePath is string => Boolean(filePath));
+ if (relativePaths.length === 0) {
+ return this.getCurrentCheckpointHash(sessionId);
+ }
+
+ try {
+ const parentHash = this.ensureSession(sessionId);
+ 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();
+ if (treeHash === parentTreeHash) {
+ return parentHash;
+ }
+
+ const commitHash = this.createCommit(treeHash, parentHash, message);
+ this.runGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false });
+ return commitHash;
+ } catch {
+ return undefined;
+ }
+ }
+
+ canRestore(sessionId: string, checkpointHash: string): boolean {
+ if (!isCommitHash(checkpointHash)) {
+ return false;
+ }
+ if (!this.getSessionBranchRef(sessionId)) {
+ return false;
+ }
+ if (!fs.existsSync(this.gitDir)) {
+ return false;
+ }
+
+ try {
+ this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ restore(sessionId: string, checkpointHash: string): void {
+ if (!isCommitHash(checkpointHash)) {
+ throw new Error("Invalid checkpoint hash.");
+ }
+ const branchRef = this.getSessionBranchRef(sessionId);
+ if (!branchRef || !fs.existsSync(this.gitDir)) {
+ throw new Error("File history Git repository was not found for this project.");
+ }
+ this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
+
+ 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.
+ }
+ this.runGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true });
+ this.runGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false });
+ }
+
+ private getSessionBranchRef(sessionId: string): string | null {
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
+ return null;
+ }
+ return `refs/heads/${sessionId}`;
+ }
+
+ private createCommit(treeHash: string, parentHash: string | null, message: string): string {
+ const args = ["commit-tree", treeHash];
+ if (parentHash) {
+ args.push("-p", parentHash);
+ }
+ args.push("-m", message);
+ return this.runGit(args, {
+ 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;
+ }
+ return relativePath.split(path.sep).join("/");
+ }
+
+ private runGit(
+ args: string[],
+ options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv }
+ ): string {
+ const gitArgs = [`--git-dir=${this.gitDir}`];
+ if (options.includeWorkTree) {
+ gitArgs.push(`--work-tree=${this.projectRoot}`);
+ }
+ gitArgs.push(...args);
+ const result = childProcess.spawnSync("git", gitArgs, {
+ encoding: "utf8",
+ input: options.input,
+ env: options.env,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ if (result.status !== 0) {
+ const detail = (result.stderr || result.stdout || "").trim();
+ throw new Error(detail || `git ${args.join(" ")} failed`);
+ }
+ return result.stdout ?? "";
+ }
+}
+
+function getFileHistoryGitEnv(): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME,
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL,
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME,
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL,
+ };
+}
+
+function isCommitHash(value: string): boolean {
+ return /^[0-9a-f]{40}$/i.test(value);
+}
diff --git a/src/session.ts b/src/session.ts
index 6b2ceee..88e85b6 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -2,7 +2,6 @@ import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
-import * as childProcess from "child_process";
import { fileURLToPath } from "url";
import matter from "gray-matter";
import ejs from "ejs";
@@ -29,14 +28,13 @@ import type { McpServerConfig } 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";
const MAX_SESSION_ENTRIES = 50;
const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new";
const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000;
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
-const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint";
-const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost";
type ChatCompletionDebugOptions = {
enabled?: boolean;
@@ -1585,113 +1583,40 @@ ${skillMd}
return { projectCode, projectDir, sessionsIndexPath };
}
+ private getFileHistory(): GitFileHistory {
+ return new GitFileHistory(this.projectRoot, this.getFileHistoryGitDir());
+ }
+
private getFileHistoryGitDir(): string {
const { projectDir } = this.getProjectStorage();
return path.join(projectDir, "file-history", ".git");
}
- private getSessionBranchRef(sessionId: string): string | null {
- if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
- return null;
- }
- return `refs/heads/${sessionId}`;
- }
-
private ensureFileHistorySession(sessionId: string): string | undefined {
- const branchRef = this.getSessionBranchRef(sessionId);
- if (!branchRef) {
- return undefined;
- }
-
- try {
- const gitDir = this.getFileHistoryGitDir();
- if (!fs.existsSync(gitDir)) {
- fs.mkdirSync(path.dirname(gitDir), { recursive: true });
- this.runFileHistoryGit(["init"], { includeWorkTree: true });
- }
-
- const current = this.getCurrentCheckpointHash(sessionId);
- if (current) {
- return current;
- }
-
- const emptyTree = this.runFileHistoryGit(["mktree"], { includeWorkTree: false, input: "" }).trim();
- const commitHash = this.createFileHistoryCommit(emptyTree, null, "Initial checkpoint");
- this.runFileHistoryGit(["update-ref", branchRef, commitHash], { includeWorkTree: false });
- return commitHash;
- } catch {
- return undefined;
- }
+ return this.getFileHistory().ensureSession(sessionId);
}
private getCurrentCheckpointHash(sessionId: string): string | undefined {
- const gitDir = this.getFileHistoryGitDir();
- const branchRef = this.getSessionBranchRef(sessionId);
- if (!branchRef || !fs.existsSync(gitDir)) {
- return undefined;
- }
-
- try {
- const hash = this.runFileHistoryGit(["rev-parse", "--verify", `${branchRef}^{commit}`], {
- includeWorkTree: false,
- }).trim();
- return this.isCommitHash(hash) ? hash : undefined;
- } catch {
- return undefined;
- }
+ return this.getFileHistory().getCurrentCheckpointHash(sessionId);
}
private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void {
- const previousHash = this.ensureFileHistorySession(sessionId);
+ const fileHistory = this.getFileHistory();
+ const previousHash = fileHistory.ensureSession(sessionId);
if (!previousHash) {
return;
}
this.updateLatestUserCheckpointHash(sessionId, undefined, previousHash);
- const nextHash = this.recordFileHistoryCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint");
+ const nextHash = fileHistory.recordCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint");
if (nextHash && nextHash !== previousHash) {
this.updateLatestUserCheckpointHash(sessionId, previousHash, nextHash);
}
}
private recordFileMutationCheckpoint(sessionId: string, filePath: string): void {
- this.ensureFileHistorySession(sessionId);
- this.recordFileHistoryCheckpoint(sessionId, [filePath], "File mutation checkpoint");
- }
-
- private recordFileHistoryCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined {
- const branchRef = this.getSessionBranchRef(sessionId);
- if (!branchRef) {
- return undefined;
- }
-
- const relativePaths = filePaths
- .map((filePath) => this.toProjectRelativeGitPath(filePath))
- .filter((filePath): filePath is string => Boolean(filePath));
- if (relativePaths.length === 0) {
- return this.getCurrentCheckpointHash(sessionId);
- }
-
- try {
- const parentHash = this.ensureFileHistorySession(sessionId);
- if (!parentHash) {
- return undefined;
- }
- this.runFileHistoryGit(["read-tree", "--reset", branchRef], { includeWorkTree: true });
- this.runFileHistoryGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true });
- const treeHash = this.runFileHistoryGit(["write-tree"], { includeWorkTree: false }).trim();
- const parentTreeHash = this.runFileHistoryGit(["rev-parse", `${parentHash}^{tree}`], {
- includeWorkTree: false,
- }).trim();
- if (treeHash === parentTreeHash) {
- return parentHash;
- }
-
- const commitHash = this.createFileHistoryCommit(treeHash, parentHash, message);
- this.runFileHistoryGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false });
- return commitHash;
- } catch {
- return undefined;
- }
+ const fileHistory = this.getFileHistory();
+ fileHistory.ensureSession(sessionId);
+ fileHistory.recordCheckpoint(sessionId, [filePath], "File mutation checkpoint");
}
private updateLatestUserCheckpointHash(sessionId: string, previousHash: string | undefined, nextHash: string): void {
@@ -1714,103 +1639,12 @@ ${skillMd}
}
}
- private createFileHistoryCommit(treeHash: string, parentHash: string | null, message: string): string {
- const args = ["commit-tree", treeHash];
- if (parentHash) {
- args.push("-p", parentHash);
- }
- args.push("-m", message);
- return this.runFileHistoryGit(args, {
- includeWorkTree: false,
- env: this.getFileHistoryGitEnv(),
- }).trim();
- }
-
- private getFileHistoryGitEnv(): NodeJS.ProcessEnv {
- return {
- ...process.env,
- GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME,
- GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL,
- GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME,
- GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL,
- };
- }
-
- 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;
- }
- return relativePath.split(path.sep).join("/");
- }
-
private canRestoreCheckpointHash(sessionId: string, checkpointHash: string): boolean {
- if (!this.isCommitHash(checkpointHash)) {
- return false;
- }
- if (!this.getSessionBranchRef(sessionId)) {
- return false;
- }
- const gitDir = this.getFileHistoryGitDir();
- if (!fs.existsSync(gitDir)) {
- return false;
- }
-
- try {
- this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
- return true;
- } catch {
- return false;
- }
+ return this.getFileHistory().canRestore(sessionId, checkpointHash);
}
private restoreCheckpointHash(sessionId: string, checkpointHash: string): void {
- if (!this.isCommitHash(checkpointHash)) {
- throw new Error("Invalid checkpoint hash.");
- }
- const gitDir = this.getFileHistoryGitDir();
- const branchRef = this.getSessionBranchRef(sessionId);
- if (!branchRef || !fs.existsSync(gitDir)) {
- throw new Error("File history Git repository was not found for this project.");
- }
- this.runFileHistoryGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
-
- try {
- this.runFileHistoryGit(["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.
- }
- this.runFileHistoryGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true });
- this.runFileHistoryGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false });
- }
-
- private runFileHistoryGit(
- args: string[],
- options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv }
- ): string {
- const gitDir = this.getFileHistoryGitDir();
- const gitArgs = [`--git-dir=${gitDir}`];
- if (options.includeWorkTree) {
- gitArgs.push(`--work-tree=${this.projectRoot}`);
- }
- gitArgs.push(...args);
- const result = childProcess.spawnSync("git", gitArgs, {
- encoding: "utf8",
- input: options.input,
- env: options.env,
- stdio: ["pipe", "pipe", "pipe"],
- });
- if (result.status !== 0) {
- const detail = (result.stderr || result.stdout || "").trim();
- throw new Error(detail || `git ${args.join(" ")} failed`);
- }
- return result.stdout ?? "";
- }
-
- private isCommitHash(value: string): boolean {
- return /^[0-9a-f]{40}$/i.test(value);
+ this.getFileHistory().restore(sessionId, checkpointHash);
}
private isUndoTargetMessage(message: SessionMessage): boolean {
From 057e3538b286ca5f67a4968674014baf0dbdc808 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 12:03:31 +0800
Subject: [PATCH 54/95] fix: Bash timeout session test now uses a temporary
HOME
---
src/tests/session.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index c02c0fa..3658e1c 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -1919,6 +1919,9 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () =
test("SessionManager adjusts the active Bash timeout control and session metadata", async () => {
const workspace = createTempDir("deepcode-bash-timeout-session-");
+ const home = createTempDir("deepcode-bash-timeout-home-");
+ setHomeDir(home);
+
const manager = createSessionManager(workspace, "");
const sessionId = await manager.createSession({ text: "hello" });
From f37ee2b71c1320820bd0e9b970a45fea4815556f Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 14:00:28 +0800
Subject: [PATCH 55/95] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E6=8F=90=E5=8F=8A=E8=8F=9C=E5=8D=95=E7=BB=84=E4=BB=B6?=
=?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96PromptInput=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 FileMentionMenu 组件用于文件提及功能显示与交互
- 在 components/index.ts 中导出 FileMentionMenu 组件
- PromptInput 集成 FileMentionMenu 替换原 DropdownMenu 实现
- 重构 PromptInput 相关状态管理及事件处理,移除冗余索引状态
- 提取 resetPromptInput 函数简化重置输入框逻辑
- 优化键盘事件处理逻辑,简化文件提及菜单快捷键支持
- 使用 FileMentionMenu 替换内联菜单渲染,提高代码复用性与可维护性
---
src/ui/PromptInput.tsx | 129 +++++---------------
src/ui/components/FileMentionMenu/index.tsx | 117 ++++++++++++++++++
src/ui/components/index.ts | 1 +
3 files changed, 147 insertions(+), 100 deletions(-)
create mode 100644 src/ui/components/FileMentionMenu/index.tsx
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index a79fe30..44e1754 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -50,8 +50,7 @@ import type { InputKey } from "./prompt";
import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
import SlashCommandMenu from "./SlashCommandMenu";
import type { ModelConfigSelection } from "../settings";
-import DropdownMenu from "./DropdownMenu";
-import { ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components";
+import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components";
export type PromptSubmission = {
text: string;
@@ -127,7 +126,6 @@ export const PromptInput = React.memo(function PromptInput({
const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false);
const [showModelDropdown, setShowModelDropdown] = useState(false);
const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot));
- const [fileMentionIndex, setFileMentionIndex] = useState(0);
const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null);
const [historyCursor, setHistoryCursor] = useState(-1);
const [draftBeforeHistory, setDraftBeforeHistory] = useState(null);
@@ -213,16 +211,6 @@ export const PromptInput = React.memo(function PromptInput({
}
}, [fileMentionKey]);
- useEffect(() => {
- if (!showFileMentionMenu) {
- setFileMentionIndex(0);
- return;
- }
- if (fileMentionIndex >= fileMentionMatches.length) {
- setFileMentionIndex(Math.max(0, fileMentionMatches.length - 1));
- }
- }, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]);
-
useEffect(() => {
if (!statusMessage) {
return;
@@ -252,8 +240,7 @@ export const PromptInput = React.memo(function PromptInput({
}
if (key.escape) {
- if (showFileMentionMenu && fileMentionKey) {
- setDismissedFileMentionKey(fileMentionKey);
+ if (showFileMentionMenu) {
return;
}
if (busy) {
@@ -345,32 +332,9 @@ export const PromptInput = React.memo(function PromptInput({
const isPlainReturn = returnAction === "submit";
if (showFileMentionMenu) {
- if (key.upArrow) {
- if (fileMentionMatches.length > 0) {
- setFileMentionIndex((idx) => (idx - 1 + fileMentionMatches.length) % fileMentionMatches.length);
- }
+ if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") {
return;
}
- if (key.downArrow) {
- if (fileMentionMatches.length > 0) {
- setFileMentionIndex((idx) => (idx + 1) % fileMentionMatches.length);
- }
- return;
- }
- if (key.tab || returnAction === "submit") {
- const selected = fileMentionMatches[fileMentionIndex];
- if (selected && fileMentionToken) {
- insertFileMentionSelection(selected);
- return;
- }
- if (key.tab) {
- setDismissedFileMentionKey(fileMentionKey);
- return;
- }
- if (fileMentionKey) {
- setDismissedFileMentionKey(fileMentionKey);
- }
- }
}
if (showMenu) {
@@ -613,6 +577,14 @@ export const PromptInput = React.memo(function PromptInput({
setDismissedFileMentionKey(null);
}
+ function resetPromptInput(): void {
+ setBuffer(EMPTY_BUFFER);
+ clearUndoRedoStacks();
+ setImageUrls([]);
+ setSelectedSkills([]);
+ setShowSkillsDropdown(false);
+ }
+
function handleSlashSelection(item: SlashCommandItem): void {
if (busy && item.kind !== "exit") {
setStatusMessage("wait for the current response or press esc to interrupt");
@@ -643,47 +615,27 @@ export const PromptInput = React.memo(function PromptInput({
}
if (item.kind === "new") {
onSubmit({ text: "", imageUrls: [], command: "new" });
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
return;
}
if (item.kind === "init") {
onSubmit(buildInitPromptSubmission(selectedSkills));
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
return;
}
if (item.kind === "resume") {
onSubmit({ text: "", imageUrls: [], command: "resume" });
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
return;
}
if (item.kind === "continue") {
onSubmit({ text: "/continue", imageUrls: [], command: "continue" });
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
return;
}
if (item.kind === "mcp") {
onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" });
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
return;
}
if (item.kind === "exit") {
@@ -718,11 +670,7 @@ export const PromptInput = React.memo(function PromptInput({
imageUrls,
selectedSkills,
});
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
}
function addSelectedSkill(skill: SkillInfo): void {
@@ -798,37 +746,18 @@ export const PromptInput = React.memo(function PromptInput({
onModelConfigChange={onModelConfigChange}
onStatusMessage={setStatusMessage}
/>
- {showFileMentionMenu ? (
- ({
- key: item.path,
- label: item.path,
- description: item.type === "directory" ? "directory" : "file",
- }))}
- activeIndex={fileMentionIndex}
- activeColor="#229ac3"
- maxVisible={8}
- renderItem={(item, isActive) => (
-
- {isActive ? "> " : " "}
-
-
- {item.label}
-
-
- {item.description ? (
-
- {item.description}
-
- ) : null}
-
- )}
- />
- ) : null}
+ {
+ if (fileMentionKey) {
+ setDismissedFileMentionKey(fileMentionKey);
+ }
+ }}
+ onSelect={insertFileMentionSelection}
+ />
{!showFooterText && (
diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx
new file mode 100644
index 0000000..ce9a8ee
--- /dev/null
+++ b/src/ui/components/FileMentionMenu/index.tsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useState } from "react";
+import { Box, Text } from "ink";
+import { useInput } from "ink";
+import DropdownMenu from "../../DropdownMenu";
+import type { FileMentionItem, FileMentionToken } from "../../fileMentions";
+
+type Props = {
+ open: boolean;
+ width: number;
+ token: FileMentionToken | null;
+ items: FileMentionItem[];
+ onClose: () => void;
+ onSelect: (item: FileMentionItem) => void;
+};
+
+const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, onSelect }) => {
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // Reset index when opened
+ useEffect(() => {
+ if (open) {
+ setActiveIndex(0);
+ }
+ }, [open]);
+
+ // Validate activeIndex bounds
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ if (items.length === 0) {
+ setActiveIndex(0);
+ return;
+ }
+ if (activeIndex >= items.length) {
+ setActiveIndex(Math.max(0, items.length - 1));
+ }
+ }, [activeIndex, items.length, open]);
+
+ useInput(
+ (input, key) => {
+ if (!open) {
+ return;
+ }
+
+ if (key.escape) {
+ onClose();
+ return;
+ }
+
+ if (key.upArrow) {
+ if (items.length > 0) {
+ setActiveIndex((idx) => (idx - 1 + items.length) % items.length);
+ }
+ return;
+ }
+
+ if (key.downArrow) {
+ if (items.length > 0) {
+ setActiveIndex((idx) => (idx + 1) % items.length);
+ }
+ return;
+ }
+
+ if (key.tab || (key.return && !key.shift && !key.meta)) {
+ const selected = items[activeIndex];
+ if (selected) {
+ onSelect(selected);
+ return;
+ }
+ if (key.tab) {
+ onClose();
+ }
+ return;
+ }
+ },
+ { isActive: open }
+ );
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+ ({
+ key: item.path,
+ label: item.path,
+ description: item.type === "directory" ? "directory" : "file",
+ }))}
+ activeIndex={activeIndex}
+ activeColor="#229ac3"
+ maxVisible={8}
+ renderItem={(item, isActive) => (
+
+ {isActive ? "> " : " "}
+
+
+ {item.label}
+
+
+ {item.description ? (
+
+ {item.description}
+
+ ) : null}
+
+ )}
+ />
+ );
+};
+
+export default FileMentionMenu;
diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts
index 1d929f3..635f733 100644
--- a/src/ui/components/index.ts
+++ b/src/ui/components/index.ts
@@ -3,3 +3,4 @@ export { MessageView } from "./MessageView";
export { RawModeExitPrompt } from "./RawModeExitPrompt";
export { default as SkillsDropdown } from "./SkillsDropdown";
export { default as ModelsDropdown } from "./ModelsDropdown";
+export { default as FileMentionMenu } from "./FileMentionMenu";
From f611c948fd117c105a7610ca0b8700e181c95140 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 14:15:30 +0800
Subject: [PATCH 56/95] fix: update git command options to handle line endings
consistently
---
src/common/file-history.ts | 2 +-
src/tests/session.test.ts | 16 ++++++++++------
2 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/src/common/file-history.ts b/src/common/file-history.ts
index 5194e6e..d5966d9 100644
--- a/src/common/file-history.ts
+++ b/src/common/file-history.ts
@@ -160,7 +160,7 @@ export class GitFileHistory {
args: string[],
options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv }
): string {
- const gitArgs = [`--git-dir=${this.gitDir}`];
+ const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`];
if (options.includeWorkTree) {
gitArgs.push(`--work-tree=${this.projectRoot}`);
}
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index 3658e1c..d5191fa 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -2018,12 +2018,16 @@ function runFileHistoryGit(
input = "",
env: NodeJS.ProcessEnv = process.env
): string {
- return execFileSync("git", [`--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], {
- encoding: "utf8",
- input,
- env,
- stdio: ["pipe", "pipe", "pipe"],
- });
+ return execFileSync(
+ "git",
+ ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args],
+ {
+ encoding: "utf8",
+ input,
+ env,
+ stdio: ["pipe", "pipe", "pipe"],
+ }
+ );
}
function fileHistoryCommitEnv(): NodeJS.ProcessEnv {
From db6f0c6991095b194f224988e1a72d769f860483 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 14:24:59 +0800
Subject: [PATCH 57/95] =?UTF-8?q?feat(SessionList):=20=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E6=90=9C=E7=B4=A2=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD=E5=8F=8A?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=B1=95=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增filterSessions函数支持根据关键词过滤会话,包括摘要、状态、失败原因和助手回复
- 支持搜索查询框,动态过滤并显示匹配的会话列表
- 实现格式化会话状态显示,如"completed"显示为"done"
- 搜索过程中支持编辑和清除查询内容,按Esc键清除搜索或退出
- 会话列表根据搜索结果自动调整高亮和滚动
- 增强交互提示,根据搜索状态显示不同的快捷键说明
- 针对新增功能添加了大量单元测试覆盖各种匹配和边界情况
- 导出filterSessions和formatSessionStatus以供外部使用
---
src/tests/sessionList.test.ts | 108 +++++++++++++++-
src/ui/SessionList.tsx | 227 ++++++++++++++++++++++++++--------
src/ui/index.ts | 2 +-
3 files changed, 280 insertions(+), 57 deletions(-)
diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts
index e3bf51c..3dfda33 100644
--- a/src/tests/sessionList.test.ts
+++ b/src/tests/sessionList.test.ts
@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { formatSessionTitle } from "../ui";
+import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui";
+import type { SessionEntry } from "../session";
test("formatSessionTitle replaces newlines with spaces", () => {
assert.equal(formatSessionTitle("first line\nsecond line\r\nthird"), "first line second line third");
@@ -9,3 +10,108 @@ test("formatSessionTitle replaces newlines with spaces", () => {
test("formatSessionTitle truncates after normalizing whitespace", () => {
assert.equal(formatSessionTitle("one\n two three", 10), "one two th…");
});
+
+test("formatSessionStatus maps status values to display labels", () => {
+ assert.equal(formatSessionStatus("completed"), "done");
+ assert.equal(formatSessionStatus("processing"), "running");
+ assert.equal(formatSessionStatus("pending"), "pending");
+ assert.equal(formatSessionStatus("waiting_for_user"), "waiting");
+ assert.equal(formatSessionStatus("failed"), "failed");
+ assert.equal(formatSessionStatus("interrupted"), "stopped");
+ assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status");
+});
+
+test("filterSessions returns all sessions when query is empty", () => {
+ const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]);
+ assert.equal(filterSessions(sessions, "").length, 2);
+ assert.equal(filterSessions(sessions, " ").length, 2);
+});
+
+test("filterSessions matches by summary (case-insensitive)", () => {
+ const sessions = buildSessions([
+ { summary: "Fix login bug" },
+ { summary: "Add dark mode" },
+ { summary: "Refactor auth module" },
+ ]);
+
+ assert.equal(filterSessions(sessions, "login").length, 1);
+ assert.equal(filterSessions(sessions, "LOGIN").length, 1);
+ assert.equal(filterSessions(sessions, "Login").length, 1);
+});
+
+test("filterSessions matches by status (case-insensitive)", () => {
+ const sessions = buildSessions([
+ { summary: "Task 1", status: "completed" },
+ { summary: "Task 2", status: "failed" },
+ { summary: "Task 3", status: "completed" },
+ ]);
+
+ assert.equal(filterSessions(sessions, "failed").length, 1);
+ assert.equal(filterSessions(sessions, "completed").length, 2);
+});
+
+test("filterSessions matches by failReason", () => {
+ const sessions = buildSessions([
+ { summary: "Task 1", status: "failed", failReason: "API key not found" },
+ { summary: "Task 2", status: "completed" },
+ ]);
+
+ assert.equal(filterSessions(sessions, "API key").length, 1);
+ assert.equal(filterSessions(sessions, "not found").length, 1);
+});
+
+test("filterSessions matches by assistantReply", () => {
+ const sessions = buildSessions([
+ { summary: "Task 1", assistantReply: "The bug was fixed by updating the config." },
+ { summary: "Task 2", assistantReply: "Dark mode has been added successfully." },
+ ]);
+
+ assert.equal(filterSessions(sessions, "dark mode").length, 1);
+ assert.equal(filterSessions(sessions, "config").length, 1);
+});
+
+test("filterSessions returns empty array when no match", () => {
+ const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]);
+
+ assert.equal(filterSessions(sessions, "nonexistent").length, 0);
+});
+
+test("filterSessions matches across multiple fields on same session", () => {
+ const sessions = buildSessions([
+ { summary: "Fix login bug", status: "failed", failReason: "Timeout error" },
+ { summary: "Add dark mode", status: "completed" },
+ ]);
+
+ // Should match the first session via status
+ assert.equal(filterSessions(sessions, "failed").length, 1);
+ // Should match the first session via failReason
+ assert.equal(filterSessions(sessions, "timeout").length, 1);
+ // Partial summary match
+ assert.equal(filterSessions(sessions, "login").length, 1);
+});
+
+test("filterSessions handles sessions with null fields", () => {
+ const sessions = buildSessions([{ summary: null }, { summary: "Valid summary" }]);
+
+ assert.equal(filterSessions(sessions, "valid").length, 1);
+ assert.equal(filterSessions(sessions, "summary").length, 1);
+});
+
+function buildSessions(overrides: Array>): SessionEntry[] {
+ return overrides.map((override, i) => ({
+ id: `session-${i}`,
+ summary: override.summary ?? null,
+ assistantReply: override.assistantReply ?? null,
+ assistantThinking: null,
+ assistantRefusal: null,
+ toolCalls: null,
+ status: override.status ?? "completed",
+ failReason: override.failReason ?? null,
+ usage: null,
+ usagePerModel: null,
+ activeTokens: 0,
+ createTime: new Date().toISOString(),
+ updateTime: new Date().toISOString(),
+ processes: null,
+ }));
+}
diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx
index fdbd1fe..ab3bf75 100644
--- a/src/ui/SessionList.tsx
+++ b/src/ui/SessionList.tsx
@@ -1,6 +1,6 @@
-import React, { useState, useMemo } from "react";
+import React, { useState, useMemo, useCallback } from "react";
import { Box, Text, useInput, useWindowSize } from "ink";
-import type { SessionEntry } from "../session";
+import type { SessionEntry, SessionStatus } from "../session";
type Props = {
sessions: SessionEntry[];
@@ -8,25 +8,57 @@ type Props = {
onCancel: () => void;
};
+/**
+ * Filter sessions by a search query.
+ * Matches against summary, status, and failReason fields (case-insensitive).
+ * Returns all sessions when query is empty.
+ */
+export function filterSessions(sessions: SessionEntry[], query: string): SessionEntry[] {
+ if (!query.trim()) {
+ return sessions;
+ }
+
+ const lowerQuery = query.toLowerCase().trim();
+ return sessions.filter((session) => {
+ if (session.summary && session.summary.toLowerCase().includes(lowerQuery)) {
+ return true;
+ }
+ if (session.status.toLowerCase().includes(lowerQuery)) {
+ return true;
+ }
+ if (session.failReason && session.failReason.toLowerCase().includes(lowerQuery)) {
+ return true;
+ }
+ if (session.assistantReply && session.assistantReply.toLowerCase().includes(lowerQuery)) {
+ return true;
+ }
+ return false;
+ });
+}
+
export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement {
const [index, setIndex] = useState(0);
+ const [searchQuery, setSearchQuery] = useState("");
const { columns, rows } = useWindowSize();
+ // Filter sessions by search query
+ const filteredSessions = useMemo(() => filterSessions(sessions, searchQuery), [sessions, searchQuery]);
+
+ // Reset index when filtered list changes (e.g., query changes)
+ const safeIndex = useMemo(() => {
+ if (filteredSessions.length === 0) return 0;
+ return Math.max(0, Math.min(index, filteredSessions.length - 1));
+ }, [index, filteredSessions.length]);
+
// Dynamically calculate the number of visible sessions based on terminal height
const maxVisibleSessions = useMemo(() => {
- // Subtract space used by borders, header, footer, scroll indicator, etc.
- // Outer container height=rows-1, outer border 2 + header 1 + inner border 2 + footer 1 + scroll indicator 1 = 8
- const reservedLines = 8;
+ // Subtract space used by borders, header (2 lines with search bar), footer, scroll indicator, etc.
+ // Outer container height=rows-1, outer border 2 + header 2 + search bar 1 + inner border 2 + footer 1 + scroll indicator 1 = 9
+ const reservedLines = searchQuery ? 12 : 9;
const linesPerSession = 3; // height=2 + marginBottom=1
const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines);
return Math.max(1, Math.floor(availableLines / linesPerSession));
- }, [rows]);
-
- // Ensure index stays within valid range
- const safeIndex = useMemo(() => {
- if (sessions.length === 0) return 0;
- return Math.max(0, Math.min(index, sessions.length - 1));
- }, [index, sessions.length]);
+ }, [rows, searchQuery]);
// Calculate scroll offset to keep the selected item visible
const scrollOffset = useMemo(() => {
@@ -36,23 +68,63 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
// Get the currently visible session list
const visibleSessions = useMemo(() => {
- return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions);
- }, [sessions, scrollOffset, maxVisibleSessions]);
+ return filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleSessions);
+ }, [filteredSessions, scrollOffset, maxVisibleSessions]);
+
+ // Handle backspace for search query
+ const handleBackspace = useCallback(() => {
+ setSearchQuery((prev) => prev.slice(0, -1));
+ setIndex(0);
+ }, []);
useInput((input, key) => {
- if (key.escape || (key.ctrl && (input === "c" || input === "C"))) {
+ // ESC: clear search first, then cancel
+ if (key.escape) {
+ if (searchQuery) {
+ setSearchQuery("");
+ setIndex(0);
+ return;
+ }
onCancel();
return;
}
- if (sessions.length === 0) {
+
+ // Ctrl+C also cancels
+ if (key.ctrl && (input === "c" || input === "C")) {
+ onCancel();
return;
}
+
+ // Backspace / Delete: remove last search character
+ if (key.backspace || key.delete) {
+ if (searchQuery) {
+ handleBackspace();
+ return;
+ }
+ // If no search query, navigation keys below handle the rest
+ }
+
+ // Printable character: append to search query
+ if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab) {
+ // Ignore if it's a named key that happens to have input (safety check)
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
+ return;
+ }
+ setSearchQuery((prev) => prev + input);
+ setIndex(0);
+ return;
+ }
+
+ if (filteredSessions.length === 0) {
+ return;
+ }
+
if (key.upArrow) {
setIndex((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow) {
- setIndex((i) => Math.min(sessions.length - 1, i + 1));
+ setIndex((i) => Math.min(filteredSessions.length - 1, i + 1));
return;
}
if (key.pageUp) {
@@ -60,7 +132,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
return;
}
if (key.pageDown) {
- setIndex((i) => Math.min(sessions.length - 1, i + maxVisibleSessions));
+ setIndex((i) => Math.min(filteredSessions.length - 1, i + maxVisibleSessions));
return;
}
if (key.home) {
@@ -68,17 +140,19 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
return;
}
if (key.end) {
- setIndex(sessions.length - 1);
+ setIndex(filteredSessions.length - 1);
return;
}
if (key.return) {
- const session = sessions[safeIndex];
+ const session = filteredSessions[safeIndex];
if (session) {
onSelect(session.id);
}
}
});
+ const hasActiveSearch = searchQuery.trim().length > 0;
+
if (sessions.length === 0) {
return (
@@ -99,15 +173,24 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
>
{/* Header row */}
-
-
- Resume a session
-
-
- {" "}
- ({sessions.length} total)
-
+
+
+
+ Resume a session
+
+
+ {" "}
+ ({sessions.length} total
+ {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""})
+
+
+ {/* Search bar */}
+
+ {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"}
+ {searchQuery ? | : null}
+
+
{/* Session list */}
- {visibleSessions.map((session, i) => {
- const actualIndex = scrollOffset + i;
- return (
-
-
- {actualIndex === safeIndex ? "> " : " "}
-
-
-
-
- {formatSessionTitle(session.summary || "Untitled")}
-
- ({session.status})
+ {filteredSessions.length === 0 ? (
+
+ No sessions match "{searchQuery}".
+
+ ) : (
+ visibleSessions.map((session, i) => {
+ const actualIndex = scrollOffset + i;
+ return (
+
+
+ {actualIndex === safeIndex ? "> " : " "}
-
- {formatTimestamp(session.updateTime)}
+
+
+
+ {formatSessionTitle(session.summary || "Untitled")}
+
+ ({formatSessionStatus(session.status)})
+
+
+ {formatTimestamp(session.updateTime)}
+
-
- );
- })}
- {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? (
+ );
+ })
+ )}
+ {scrollOffset > 0 || scrollOffset + maxVisibleSessions < filteredSessions.length ? (
- {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null}
- {scrollOffset + maxVisibleSessions < sessions.length ? (
- … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below.
+ {scrollOffset > 0 ? … {scrollOffset} sessions above. : null}
+ {scrollOffset + maxVisibleSessions < filteredSessions.length ? (
+ … {filteredSessions.length - scrollOffset - maxVisibleSessions} sessions below.
) : null}
) : null}
{/* Footer */}
-
- ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel
+
+ {hasActiveSearch ? (
+
+ Esc clear search ·
+ ↑/↓ navigate · Enter select · Esc again to cancel
+
+ ) : (
+
+ Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel
+
+ )}
@@ -179,6 +277,25 @@ export function formatSessionTitle(value: string, max = 70): string {
return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max);
}
+export function formatSessionStatus(status: SessionStatus): string {
+ switch (status) {
+ case "completed":
+ return "done";
+ case "processing":
+ return "running";
+ case "pending":
+ return "pending";
+ case "waiting_for_user":
+ return "waiting";
+ case "failed":
+ return "failed";
+ case "interrupted":
+ return "stopped";
+ default:
+ return status;
+ }
+}
+
function truncate(value: string, max: number): string {
if (value.length <= max) {
return value;
diff --git a/src/ui/index.ts b/src/ui/index.ts
index efb4edd..f639c65 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -37,7 +37,7 @@ export {
} from "./PromptInput";
export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS };
export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor";
-export { SessionList, formatSessionTitle } from "./SessionList";
+export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./SessionList";
export { ThemedGradient } from "./ThemedGradient";
export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt";
export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen";
From 208a9886196c404dd00c052a4bcc45e4a1ed4a0c Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 14:38:00 +0800
Subject: [PATCH 58/95] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E6=90=9C?=
=?UTF-8?q?=E7=B4=A2=E6=9F=A5=E8=AF=A2=E4=B8=AD=E5=9B=9E=E8=BD=A6=E9=94=AE?=
=?UTF-8?q?=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在处理搜索输入时忽略回车键,避免错误触发搜索操作
- 增加对回车键的判断,提升输入处理的准确性
- 防止命名键在输入查询时误触发逻辑分支
---
src/ui/SessionList.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx
index ab3bf75..5f186bd 100644
--- a/src/ui/SessionList.tsx
+++ b/src/ui/SessionList.tsx
@@ -105,7 +105,7 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
}
// Printable character: append to search query
- if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab) {
+ if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab && !key.return) {
// Ignore if it's a named key that happens to have input (safety check)
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
return;
From 585fe5ff442b0dcbfeb6a68a959c68badb386fcd Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 14:48:05 +0800
Subject: [PATCH 59/95] merge(branch): 'main' into
refactor/extract-dropdown-components
---
src/ui/PromptInput.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index 24aa076..7008cba 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -238,7 +238,6 @@ export const PromptInput = React.memo(function PromptInput({
setSelectedSkills([]);
setShowSkillsDropdown(false);
setOpenRawModelDropdown(false);
- setModelDropdownStep(null);
setHistoryCursor(-1);
setDraftBeforeHistory(null);
clearPromptUndoRedoState(undoRedoRef.current);
From 3c15003454ce743a2577fbaa278579c88e675fb6 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 14:53:10 +0800
Subject: [PATCH 60/95] refactor: extracted terminal data dispatch into
`dispatchTerminalInput()`
---
src/tests/promptInputKeys.test.ts | 51 +++++++++++++++++++++++++++
src/ui/PromptInput.tsx | 2 +-
src/ui/index.ts | 1 +
src/ui/prompt/index.ts | 2 +-
src/ui/prompt/useTerminalInput.ts | 58 +++++++++++++++++--------------
5 files changed, 86 insertions(+), 28 deletions(-)
diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts
index 54213a1..4f8b4d9 100644
--- a/src/tests/promptInputKeys.test.ts
+++ b/src/tests/promptInputKeys.test.ts
@@ -20,11 +20,23 @@ import {
renderBufferWithCursor,
buildInitPromptSubmission,
buildPromptDraftFromSessionMessage,
+ dispatchTerminalInput,
disableTerminalExtendedKeys,
enableTerminalExtendedKeys,
+ EMPTY_BUFFER,
+ insertText,
+ backspace,
} from "../ui";
import type { SessionMessage, SkillInfo } from "../session";
+function collectDispatchedInput(data: string) {
+ const events: ReturnType[] = [];
+ dispatchTerminalInput(data, (input, key) => {
+ events.push({ input, key });
+ });
+ return events;
+}
+
test("parseTerminalInput treats DEL bytes as backspace", () => {
const { input, key } = parseTerminalInput("\u007F");
assert.equal(input, "");
@@ -72,6 +84,45 @@ test("parseTerminalInput keeps DEL payload for meta+backspace", () => {
assert.equal(key.backspace, false);
});
+test("dispatchTerminalInput splits iOS CJK composition packets", () => {
+ const events = collectDispatchedInput("가\u007F나");
+ assert.equal(events.length, 3);
+ assert.equal(events[0]?.input, "가");
+ assert.equal(events[1]?.input, "");
+ assert.equal(events[1]?.key.backspace, true);
+ assert.equal(events[2]?.input, "나");
+});
+
+test("dispatchTerminalInput applies multi-step CJK composition to the prompt buffer", () => {
+ let state = EMPTY_BUFFER;
+ dispatchTerminalInput("ㄱ\u007F가\u007F각", (input, key) => {
+ if (key.backspace) {
+ state = backspace(state);
+ return;
+ }
+ state = insertText(state, input);
+ });
+
+ assert.equal(state.text, "각");
+ assert.equal(state.cursor, 1);
+});
+
+test("dispatchTerminalInput preserves meta+backspace as one event", () => {
+ const events = collectDispatchedInput("\u001B\u007F");
+ assert.equal(events.length, 1);
+ assert.equal(events[0]?.input, "\u007F");
+ assert.equal(events[0]?.key.meta, true);
+ assert.equal(events[0]?.key.backspace, false);
+ assert.equal(events[0]?.key.escape, false);
+});
+
+test("dispatchTerminalInput emits consecutive backspaces from one packet", () => {
+ const events = collectDispatchedInput("\u007F\u007F");
+ assert.equal(events.length, 2);
+ assert.equal(events[0]?.key.backspace, true);
+ assert.equal(events[1]?.key.backspace, true);
+});
+
test("parseTerminalInput keeps BS payload for meta+backspace", () => {
const { input, key } = parseTerminalInput("\u001B\b");
assert.equal(input, "\b");
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index 6767560..3cb51f2 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -42,7 +42,7 @@ import { readClipboardImageAsync } from "./clipboard";
import type { SessionEntry, SkillInfo } from "../session";
// Re-exported from prompt modules for backward compatibility
-export { useTerminalInput, parseTerminalInput } from "./prompt";
+export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt";
export type { InputKey } from "./prompt";
import { useTerminalInput } from "./prompt";
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 7c0ed15..681d77c 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -30,6 +30,7 @@ export {
MODEL_COMMAND_THINKING_OPTIONS,
useTerminalInput,
parseTerminalInput,
+ dispatchTerminalInput,
type PromptSubmission,
type PromptDraft,
type InputKey,
diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts
index a33172c..5907558 100644
--- a/src/ui/prompt/index.ts
+++ b/src/ui/prompt/index.ts
@@ -1,4 +1,4 @@
-export { useTerminalInput, parseTerminalInput } from "./useTerminalInput";
+export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./useTerminalInput";
export type { InputKey } from "./useTerminalInput";
export {
diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts
index 8fe0d60..9ce6976 100644
--- a/src/ui/prompt/useTerminalInput.ts
+++ b/src/ui/prompt/useTerminalInput.ts
@@ -169,6 +169,37 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
return { input, key };
}
+export function dispatchTerminalInput(
+ data: Buffer | string,
+ inputHandler: (input: string, key: InputKey) => void
+): void {
+ const raw = String(data);
+
+ // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.).
+ // iOS keyboards can send composed characters as a single packet like:
+ // "가\x7f나" (character + backspace + replacement character)
+ // Do not split escape-prefixed sequences such as Alt+Backspace.
+ if (!raw.startsWith("\u001B") && raw.includes("\x7f") && raw.length > 1) {
+ const parts = raw.split("\x7f");
+ if (parts[0]) {
+ const { input, key } = parseTerminalInput(parts[0]);
+ inputHandler(input, key);
+ }
+ for (let i = 1; i < parts.length; i++) {
+ const bs = parseTerminalInput("\x7f");
+ inputHandler(bs.input, bs.key);
+ if (parts[i]) {
+ const { input, key } = parseTerminalInput(parts[i]);
+ inputHandler(input, key);
+ }
+ }
+ return;
+ }
+
+ const { input, key } = parseTerminalInput(data);
+ inputHandler(input, key);
+}
+
export function useTerminalInput(
inputHandler: (input: string, key: InputKey) => void,
options: { isActive?: boolean } = {}
@@ -193,32 +224,7 @@ export function useTerminalInput(
return;
}
const handleData = (data: Buffer | string) => {
- const raw = String(data);
-
- // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.).
- // iOS keyboards send composed characters as a single packet like:
- // "가\x7f나" (character + backspace + new character)
- // Without splitting, parseTerminalInput treats the whole packet as
- // one input and drops the composition backspaces, corrupting the text.
- if (raw.includes("\x7f") && raw.length > 1) {
- const parts = raw.split("\x7f");
- if (parts[0]) {
- const { input, key } = parseTerminalInput(parts[0]);
- handlerRef.current(input, key);
- }
- for (let i = 1; i < parts.length; i++) {
- const bs = parseTerminalInput("\x7f");
- handlerRef.current(bs.input, bs.key);
- if (parts[i]) {
- const { input, key } = parseTerminalInput(parts[i]);
- handlerRef.current(input, key);
- }
- }
- return;
- }
-
- const { input, key } = parseTerminalInput(data);
- handlerRef.current(input, key);
+ dispatchTerminalInput(data, handlerRef.current);
};
stdin?.on("data", handleData);
From abde38cc76be4cd1836d04713d2f51cca294d11b Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 15:40:35 +0800
Subject: [PATCH 61/95] feat: refresh cached MCP tool definitions after server
crash
---
src/mcp/mcp-manager.ts | 1 +
src/tests/session.test.ts | 64 +++++++++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+)
diff --git a/src/mcp/mcp-manager.ts b/src/mcp/mcp-manager.ts
index 217e3fc..fe8066b 100644
--- a/src/mcp/mcp-manager.ts
+++ b/src/mcp/mcp-manager.ts
@@ -236,6 +236,7 @@ export class McpManager {
this.tools = this.tools.filter((t) => t.serverName !== name);
this.prompts = this.prompts.filter((p) => p.serverName !== name);
this.resources = this.resources.filter((r) => r.serverName !== name);
+ this.onToolsListChanged?.();
this.setStatus({
name,
status: "failed",
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index 27b504e..9f3c7fb 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -482,6 +482,60 @@ rl.on("line", (line) => {
assert.deepEqual(manager.getMcpStatus(), []);
});
+test("SessionManager refreshes cached MCP tool definitions after server crash", async () => {
+ const workspace = createTempDir("deepcode-mcp-crash-cache-workspace-");
+ const serverPath = path.join(workspace, "mcp-server-crash.cjs");
+ fs.writeFileSync(
+ serverPath,
+ `
+const readline = require("readline");
+const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+function send(message) {
+ process.stdout.write(JSON.stringify(message) + "\\n");
+}
+rl.on("line", (line) => {
+ const request = JSON.parse(line);
+ if (!("id" in request)) {
+ return;
+ }
+ if (request.method === "initialize") {
+ send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } });
+ return;
+ }
+ if (request.method === "tools/list") {
+ send({ jsonrpc: "2.0", id: request.id, result: { tools: [
+ { name: "echo", inputSchema: { type: "object", properties: {} } }
+ ] } });
+ return;
+ }
+ if (request.method === "prompts/list") {
+ send({ jsonrpc: "2.0", id: request.id, result: { prompts: [] } });
+ return;
+ }
+ if (request.method === "resources/list") {
+ send({ jsonrpc: "2.0", id: request.id, result: { resources: [] } });
+ setTimeout(() => process.exit(9), 10);
+ return;
+ }
+ send({ jsonrpc: "2.0", id: request.id, result: { content: [] } });
+});
+`,
+ "utf8"
+ );
+
+ const manager = createSessionManager(workspace, "machine-id-mcp-crash-cache");
+ await manager.initMcpServers({ crashy: { command: process.execPath, args: [serverPath] } });
+
+ assert.equal(manager.getMcpStatus()[0]?.status, "ready");
+ assert.equal((manager as any).mcpToolDefinitions.length, 1);
+
+ await waitForMcpStatus(manager, "failed");
+
+ assert.equal((manager as any).mcpToolDefinitions.length, 0);
+
+ manager.dispose();
+});
+
test("SessionManager reports configured MCP servers as starting before initialization", () => {
const workspace = createTempDir("deepcode-mcp-configured-workspace-");
const manager = new SessionManager({
@@ -2276,6 +2330,16 @@ async function waitForNotifyRecords(
assert.fail(`expected ${expectedCount} notify records in ${outputPath}`);
}
+async function waitForMcpStatus(manager: SessionManager, expectedStatus: string): Promise {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (manager.getMcpStatus()[0]?.status === expectedStatus) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ }
+ assert.fail(`expected MCP status ${expectedStatus}`);
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
From b757ea191993207f2fe7761bf58730e111eec01a Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 16:51:07 +0800
Subject: [PATCH 62/95] =?UTF-8?q?refactor(ui):=20=E7=A7=BB=E5=8A=A8?=
=?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=20isSkillSelected=20=E5=87=BD?=
=?UTF-8?q?=E6=95=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将 isSkillSelected 函数从 PromptInput.tsx 移动到 SlashCommandMenu.tsx
- 在 PromptInput.tsx 中从 SlashCommandMenu 导入 isSkillSelected
- 简化代码结构,避免函数重复定义
- 统一技能选择判断逻辑以提高代码复用性
---
src/ui/PromptInput.tsx | 6 +-----
src/ui/SlashCommandMenu.tsx | 5 ++++-
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index 5e2c2b5..074cab6 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -48,7 +48,7 @@ export type { InputKey } from "./prompt";
import { useTerminalInput } from "./prompt";
import type { InputKey } from "./prompt";
import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
-import SlashCommandMenu from "./SlashCommandMenu";
+import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu";
import type { ModelConfigSelection } from "../settings";
import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components";
@@ -818,10 +818,6 @@ export function formatSelectedSkillsStatus(skills: SkillInfo[]): string {
return `⚡ ${names.join(", ")}`;
}
-export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean {
- return skills.some((item) => item.name === skill.name);
-}
-
export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] {
if (isSkillSelected(skills, skill)) {
return skills;
diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx
index 02ff308..df599b5 100644
--- a/src/ui/SlashCommandMenu.tsx
+++ b/src/ui/SlashCommandMenu.tsx
@@ -3,6 +3,7 @@ import type { SlashCommandItem } from "./slashCommands";
import { ARGS_SEPARATOR } from "./constants";
import React from "react";
import { Box, Text } from "ink";
+import type { SkillInfo } from "../session";
type SlashCommandMenuProps = {
items: SlashCommandItem[];
@@ -10,7 +11,9 @@ type SlashCommandMenuProps = {
width: number;
maxVisible?: number;
};
-
+export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean {
+ return skills.some((item) => item.name === skill.name);
+}
const SlashCommandMenu = React.memo(function SlashCommandMenu({
items,
activeIndex,
From e0bde604fb220b63b8b43b17a72cf3b5ea0934fd Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 17:00:45 +0800
Subject: [PATCH 63/95] =?UTF-8?q?refactor(ui):=20=E7=A7=BB=E5=8A=A8?=
=?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=20isSkillSelected=20=E5=87=BD?=
=?UTF-8?q?=E6=95=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将 isSkillSelected 函数从 PromptInput.tsx 移动到 SlashCommandMenu.tsx
- 在 PromptInput.tsx 中从 SlashCommandMenu 导入 isSkillSelected
- 简化代码结构,避免函数重复定义
- 统一技能选择判断逻辑以提高代码复用性
---
src/ui/components/SkillsDropdown/index.tsx | 2 +-
src/ui/index.ts | 1 -
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx
index 545e2ab..b320d24 100644
--- a/src/ui/components/SkillsDropdown/index.tsx
+++ b/src/ui/components/SkillsDropdown/index.tsx
@@ -1,8 +1,8 @@
import DropdownMenu from "../../DropdownMenu";
import React, { useEffect, useState } from "react";
-import { isSkillSelected } from "../../PromptInput";
import type { SkillInfo } from "../../../session";
import { useInput } from "ink";
+import { isSkillSelected } from "../../SlashCommandMenu";
const SkillsDropdown: React.FC<{
open: boolean;
diff --git a/src/ui/index.ts b/src/ui/index.ts
index d634ed6..26e7eaa 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -23,7 +23,6 @@ export {
IMAGE_ATTACHMENT_CLEAR_HINT,
formatImageAttachmentStatus,
formatSelectedSkillsStatus,
- isSkillSelected,
addUniqueSkill,
toggleSkillSelection,
removeCurrentSlashToken,
From b2544b831252c5d58f15cbfa6b5c7c04e1a1aa8f Mon Sep 17 00:00:00 2001
From: lellansin
Date: Wed, 20 May 2026 17:36:41 +0800
Subject: [PATCH 64/95] perf: reuse OpenAI client and add undici keep-alive
Agent with connection warmup
Extract OpenAI client creation logic into src/common/openai-client.ts:
- Custom undici Agent with 60s keepAlive timeout (default is 4s)
- Module-level client instance cache (reuse across calls)
- Fire-and-forget connection warmup on first creation (3s timeout)
- getMachineId() helper
The App.tsx now simply imports and re-exports createOpenAIClient from
the new common module, keeping UI concerns separate from HTTP/client
lifecycle management.
---
package-lock.json | 10 +++
package.json | 1 +
src/common/openai-client.ts | 117 ++++++++++++++++++++++++++++++++++++
src/ui/App.tsx | 73 +++-------------------
src/ui/index.ts | 2 +-
5 files changed, 138 insertions(+), 65 deletions(-)
create mode 100644 src/common/openai-client.ts
diff --git a/package-lock.json b/package-lock.json
index 17a77ca..cdb85de 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"ink-gradient": "^4.0.0",
"openai": "^6.35.0",
"react": "^19.2.5",
+ "undici": "^7.25.0",
"zod": "^4.4.3"
},
"bin": {
@@ -4096,6 +4097,15 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
+ "node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
diff --git a/package.json b/package.json
index b72fd96..bf8d167 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"ink-gradient": "^4.0.0",
"openai": "^6.35.0",
"react": "^19.2.5",
+ "undici": "^7.25.0",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/src/common/openai-client.ts b/src/common/openai-client.ts
new file mode 100644
index 0000000..7f9634c
--- /dev/null
+++ b/src/common/openai-client.ts
@@ -0,0 +1,117 @@
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import OpenAI from "openai";
+import { Agent, fetch as undiciFetch } from "undici";
+import { resolveCurrentSettings } from "../ui/App";
+
+// Custom undici Agent with a 60-second keepAlive timeout. The default
+// global fetch (undici) only keeps connections alive for 4 seconds, which
+// is too short for a CLI where the user may spend 10–30 seconds reading
+// output between prompts. By passing a dedicated Agent to undiciFetch we
+// keep connections reusable for a full minute after the last request.
+const keepAliveAgent = new Agent({ keepAliveTimeout: 60_000 });
+
+// Module-level cache for the OpenAI client instance. The client itself is
+// a stateless fetch wrapper, so it is safe to share across calls as long as
+// the apiKey + baseURL stay the same. Model, thinking-mode and other
+// settings are always read fresh from the project / user config files.
+let cachedOpenAI: OpenAI | null = null;
+let cachedOpenAIKey = "";
+
+export function createOpenAIClient(projectRoot: string = process.cwd()): {
+ client: OpenAI | null;
+ model: string;
+ baseURL: string;
+ thinkingEnabled: boolean;
+ reasoningEffort: "high" | "max";
+ debugLogEnabled: boolean;
+ notify?: string;
+ webSearchTool?: string;
+ env: Record;
+ machineId?: string;
+} {
+ const settings = resolveCurrentSettings(projectRoot);
+ if (!settings.apiKey) {
+ return {
+ client: null,
+ model: settings.model,
+ baseURL: settings.baseURL,
+ thinkingEnabled: settings.thinkingEnabled,
+ reasoningEffort: settings.reasoningEffort,
+ debugLogEnabled: settings.debugLogEnabled,
+ notify: settings.notify,
+ webSearchTool: settings.webSearchTool,
+ env: settings.env,
+ machineId: getMachineId(),
+ };
+ }
+
+ const cacheKey = `${settings.apiKey}::${settings.baseURL}`;
+ if (cachedOpenAI && cachedOpenAIKey === cacheKey) {
+ return {
+ client: cachedOpenAI,
+ model: settings.model,
+ baseURL: settings.baseURL,
+ thinkingEnabled: settings.thinkingEnabled,
+ reasoningEffort: settings.reasoningEffort,
+ debugLogEnabled: settings.debugLogEnabled,
+ notify: settings.notify,
+ webSearchTool: settings.webSearchTool,
+ env: settings.env,
+ machineId: getMachineId(),
+ };
+ }
+
+ cachedOpenAI = new OpenAI({
+ apiKey: settings.apiKey,
+ baseURL: settings.baseURL || undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }),
+ });
+ cachedOpenAIKey = cacheKey;
+
+ // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API
+ // server while the user is composing their first prompt. Bounded by a
+ // short timeout so a slow / unreachable API never blocks process exit.
+ void (async () => {
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), 3000);
+ try {
+ await cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {});
+ } finally {
+ clearTimeout(timer);
+ }
+ })();
+
+ return {
+ client: cachedOpenAI,
+ model: settings.model,
+ baseURL: settings.baseURL,
+ thinkingEnabled: settings.thinkingEnabled,
+ reasoningEffort: settings.reasoningEffort,
+ debugLogEnabled: settings.debugLogEnabled,
+ notify: settings.notify,
+ webSearchTool: settings.webSearchTool,
+ env: settings.env,
+ machineId: getMachineId(),
+ };
+}
+
+function getMachineId(): string | undefined {
+ try {
+ const idPath = path.join(os.homedir(), ".deepcode", "machine-id");
+ if (fs.existsSync(idPath)) {
+ const raw = fs.readFileSync(idPath, "utf8").trim();
+ if (raw) {
+ return raw;
+ }
+ }
+ const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`;
+ fs.mkdirSync(path.dirname(idPath), { recursive: true });
+ fs.writeFileSync(idPath, generated, "utf8");
+ return generated;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 75d6689..5419a2a 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -4,7 +4,7 @@ import chalk from "chalk";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
-import OpenAI from "openai";
+import { createOpenAIClient } from "../common/openai-client";
import {
type LlmStreamProgress,
type MessageMeta,
@@ -166,6 +166,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
void refreshSkills();
}, [refreshSessionsList, refreshSkills]);
+ // Eagerly create the OpenAI client on mount so the TCP+TLS connection
+ // warmup (fire-and-forget inside createOpenAIClient) starts before the
+ // user sends their first prompt.
+ useEffect(() => {
+ createOpenAIClient(projectRoot);
+ }, [projectRoot]);
+
useLayoutEffect(() => {
const settings = resolveCurrentSettings(projectRoot);
void sessionManager.initMcpServers(settings.mcpServers);
@@ -838,69 +845,7 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res
);
}
-export function createOpenAIClient(projectRoot: string = process.cwd()): {
- client: OpenAI | null;
- model: string;
- baseURL: string;
- thinkingEnabled: boolean;
- reasoningEffort: "high" | "max";
- debugLogEnabled: boolean;
- notify?: string;
- webSearchTool?: string;
- env: Record;
- machineId?: string;
-} {
- const settings = resolveCurrentSettings(projectRoot);
- if (!settings.apiKey) {
- return {
- client: null,
- model: settings.model,
- baseURL: settings.baseURL,
- thinkingEnabled: settings.thinkingEnabled,
- reasoningEffort: settings.reasoningEffort,
- debugLogEnabled: settings.debugLogEnabled,
- notify: settings.notify,
- webSearchTool: settings.webSearchTool,
- env: settings.env,
- machineId: getMachineId(),
- };
- }
-
- const client = new OpenAI({
- apiKey: settings.apiKey,
- baseURL: settings.baseURL || undefined,
- });
- return {
- client,
- model: settings.model,
- baseURL: settings.baseURL,
- thinkingEnabled: settings.thinkingEnabled,
- reasoningEffort: settings.reasoningEffort,
- debugLogEnabled: settings.debugLogEnabled,
- notify: settings.notify,
- webSearchTool: settings.webSearchTool,
- env: settings.env,
- machineId: getMachineId(),
- };
-}
-
-function getMachineId(): string | undefined {
- try {
- const idPath = path.join(os.homedir(), ".deepcode", "machine-id");
- if (fs.existsSync(idPath)) {
- const raw = fs.readFileSync(idPath, "utf8").trim();
- if (raw) {
- return raw;
- }
- }
- const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`;
- fs.mkdirSync(path.dirname(idPath), { recursive: true });
- fs.writeFileSync(idPath, generated, "utf8");
- return generated;
- } catch {
- return undefined;
- }
-}
+export { createOpenAIClient } from "../common/openai-client";
function getUserSettingsPath(): string {
return path.join(os.homedir(), ".deepcode", "settings.json");
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 26e7eaa..d899d4b 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -11,9 +11,9 @@ export {
writeProjectSettings,
writeModelConfigSelection,
resolveCurrentSettings,
- createOpenAIClient,
buildPromptDraftFromSessionMessage,
} from "./App";
+export { createOpenAIClient } from "../common/openai-client";
export { default as AppContainer } from "./AppContainer";
export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
export { MessageView } from "./components";
From 7578c324639fccdccee32870b193111aaeff3183 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Wed, 20 May 2026 18:12:54 +0800
Subject: [PATCH 65/95] feat: add built-in tool alias mapping
---
src/tests/tool-executor.test.ts | 41 +++++++++++++++++++++++++++++++++
src/tools/executor.ts | 10 +++++++-
2 files changed, 50 insertions(+), 1 deletion(-)
create mode 100644 src/tests/tool-executor.test.ts
diff --git a/src/tests/tool-executor.test.ts b/src/tests/tool-executor.test.ts
new file mode 100644
index 0000000..f7def2f
--- /dev/null
+++ b/src/tests/tool-executor.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, test } from "node:test";
+import assert from "node:assert/strict";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import { ToolExecutor } from "../tools/executor";
+
+const tempDirs: string[] = [];
+
+afterEach(() => {
+ while (tempDirs.length > 0) {
+ const dir = tempDirs.pop();
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+});
+
+test("ToolExecutor accepts title-case built-in tool aliases", async () => {
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-tool-executor-"));
+ tempDirs.push(workspace);
+ const filePath = path.join(workspace, "sample.txt");
+ fs.writeFileSync(filePath, "alpha\nbeta\n", "utf8");
+
+ const executor = new ToolExecutor(workspace);
+ const executions = await executor.executeToolCalls("alias-session", [
+ {
+ id: "call-read",
+ type: "function",
+ function: {
+ name: "Read",
+ arguments: JSON.stringify({ file_path: filePath })
+ }
+ }
+ ]);
+
+ assert.equal(executions.length, 1);
+ assert.equal(executions[0]?.result.ok, true);
+ assert.equal(executions[0]?.result.name, "read");
+ assert.match(executions[0]?.result.output ?? "", /alpha/);
+});
diff --git a/src/tools/executor.ts b/src/tools/executor.ts
index 73e31f5..edfca6f 100644
--- a/src/tools/executor.ts
+++ b/src/tools/executor.ts
@@ -89,6 +89,13 @@ export type ToolHandler = (
context: ToolExecutionContext
) => Promise;
+const BUILT_IN_TOOL_NAME_ALIASES = new Map([
+ ["Bash", "bash"],
+ ["Read", "read"],
+ ["Write", "write"],
+ ["Edit", "edit"]
+]);
+
export type ToolCallExecution = {
toolCallId: string;
content: string;
@@ -187,7 +194,8 @@ export class ToolExecutor {
hooks?: ToolExecutionHooks
): Promise {
const toolName = toolCall.function.name;
- const handler = this.toolHandlers.get(toolName);
+ const handlerName = BUILT_IN_TOOL_NAME_ALIASES.get(toolName) ?? toolName;
+ const handler = this.toolHandlers.get(handlerName);
if (!handler) {
// Try MCP tools
if (this.mcpManager?.isMcpTool(toolName)) {
From e424e187cfb1ecaa07f2673138464e303fa7e699 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 21:30:49 +0800
Subject: [PATCH 66/95] =?UTF-8?q?refactor(ui):=20=E4=BD=BF=E7=94=A8=20rese?=
=?UTF-8?q?tPromptInput=20=E7=AE=80=E5=8C=96=E6=92=A4=E9=94=80=E5=92=8C?=
=?UTF-8?q?=E5=9B=9E=E7=BB=95=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 替换手动状态重置为调用 resetPromptInput 函数
- 简化代码提高可读性和维护性
- 保持撤销(undo)和回绕(rewind)命令处理逻辑一致
- 移除重复的状态重置代码块
---
src/ui/PromptInput.tsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index 074cab6..d2af534 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -659,11 +659,7 @@ export const PromptInput = React.memo(function PromptInput({
}
if (item.kind === "undo") {
onSubmit({ text: "/undo", imageUrls: [], command: "undo" });
- setBuffer(EMPTY_BUFFER);
- clearUndoRedoStacks();
- setImageUrls([]);
- setSelectedSkills([]);
- setShowSkillsDropdown(false);
+ resetPromptInput();
return;
}
if (item.kind === "mcp") {
From a858684cf1d246d597552f0f16dc65bdedde422d Mon Sep 17 00:00:00 2001
From: hcyang
Date: Wed, 20 May 2026 21:36:50 +0800
Subject: [PATCH 67/95] =?UTF-8?q?fix(executor):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=E4=B8=8E=E8=AF=AD=E6=B3=95?=
=?UTF-8?q?=E9=94=99=E8=AF=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在工具类型列表的最后一项添加缺失的逗号
- 修正测试用例中 JSON 对象的格式错误
- 确保工具调用参数语法规范合理
- 修复断言前的代码缩进问题
---
src/tests/tool-executor.test.ts | 6 +++---
src/tools/executor.ts | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/tests/tool-executor.test.ts b/src/tests/tool-executor.test.ts
index f7def2f..f36def2 100644
--- a/src/tests/tool-executor.test.ts
+++ b/src/tests/tool-executor.test.ts
@@ -29,9 +29,9 @@ test("ToolExecutor accepts title-case built-in tool aliases", async () => {
type: "function",
function: {
name: "Read",
- arguments: JSON.stringify({ file_path: filePath })
- }
- }
+ arguments: JSON.stringify({ file_path: filePath }),
+ },
+ },
]);
assert.equal(executions.length, 1);
diff --git a/src/tools/executor.ts b/src/tools/executor.ts
index edfca6f..220fc89 100644
--- a/src/tools/executor.ts
+++ b/src/tools/executor.ts
@@ -93,7 +93,7 @@ const BUILT_IN_TOOL_NAME_ALIASES = new Map([
["Bash", "bash"],
["Read", "read"],
["Write", "write"],
- ["Edit", "edit"]
+ ["Edit", "edit"],
]);
export type ToolCallExecution = {
From 3a8041faa173f09fac437318f15cfefb4d9c2f78 Mon Sep 17 00:00:00 2001
From: Kayro
Date: Wed, 20 May 2026 21:30:27 +0800
Subject: [PATCH 68/95] feat(ui): add bracketed paste detection with
large-paste marker collapsing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Detect bracketed paste (ESC[200~ / ESC[201~) and dispatch as atomic paste event
- Large pastes (>10 lines or >1000 chars) are stored and replaced with a compact marker [paste #N]
- Ctrl+O toggles expand/collapse, backspace/delete atomically remove the entire marker
- Markers are highlighted with chalk.yellow and expanded back on submit
- Follows existing terminal hook patterns (useBracketedPaste alongside useTerminalExtendedKeys)
- Array-based chunk buffering to avoid O(n²) string concatenation on multi-chunk pastes
- Lazy text cleaning deferred to expand/submit time
Known limitation: expand/collapse briefly clears Ink content above the prompt (React render pipeline constraint).
Reference: PR #45 (closed), inspired by pi project's paste marker approach.
---
src/ui/PromptInput.tsx | 234 ++++++++++++++++++++++++++++--
src/ui/prompt/cursor.ts | 21 +++
src/ui/prompt/index.ts | 1 +
src/ui/prompt/useTerminalInput.ts | 110 ++++++++++++++
src/ui/promptBuffer.ts | 114 +++++++++++++++
5 files changed, 465 insertions(+), 15 deletions(-)
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index 074cab6..ad73d83 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -4,11 +4,18 @@ import chalk from "chalk";
import { ARGS_SEPARATOR } from "./constants";
import {
EMPTY_BUFFER,
+ PASTE_MARKER_REGEX,
backspace,
+ cleanPasteContent,
deleteForward,
+ deletePasteMarkerBackward,
+ deletePasteMarkerForward,
deleteWordBefore,
deleteWordAfter,
+ expandPasteMarkers,
+ findPasteMarkerContaining,
getCurrentSlashToken,
+ hasActivePasteMarkers,
insertText,
isEmpty,
killLine,
@@ -47,7 +54,12 @@ export type { InputKey } from "./prompt";
import { useTerminalInput } from "./prompt";
import type { InputKey } from "./prompt";
-import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
+import {
+ useHiddenTerminalCursor,
+ useTerminalExtendedKeys,
+ useBracketedPaste,
+ useTerminalFocusReporting,
+} from "./prompt";
import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu";
import type { ModelConfigSelection } from "../settings";
import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components";
@@ -143,6 +155,12 @@ export const PromptInput = React.memo(function PromptInput({
const wasBusyRef = React.useRef(busy);
const hadFileMentionTokenRef = React.useRef(false);
const appliedDraftNonceRef = React.useRef(null);
+ const pastesRef = React.useRef>(new Map());
+ const pasteCounterRef = React.useRef(0);
+ // Track expanded paste regions for toggle (Ctrl+O expand / collapse).
+ const expandedRegionsRef = React.useRef>(
+ new Map()
+ );
const fileMentionToken = getCurrentFileMentionToken(buffer);
const hasFileMentionToken = fileMentionToken !== null;
@@ -170,16 +188,25 @@ export const PromptInput = React.memo(function PromptInput({
const showMenu = slashMenu.length > 0;
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
const hasRunningProcess = runningProcesses && runningProcesses.size > 0;
- const processHint = hasRunningProcess ? " · ctrl+o view output" : "";
+ const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text);
+ const hasExpandedRegions = expandedRegionsRef.current.size > 0;
+ const processOrPasteHint = hasRunningProcess
+ ? " · ctrl+o view output"
+ : hasCollapsedMarkers
+ ? " · ctrl+o expand"
+ : hasExpandedRegions
+ ? " · ctrl+o collapse"
+ : "";
const footerText = statusMessage
? statusMessage
: busy
? loadingText && loadingText.trim()
- ? `${loadingText}${processHint}`
- : `esc to interrupt · ctrl+c to cancel input${processHint}`
- : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processHint}`;
+ ? `${loadingText}${processOrPasteHint}`
+ : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`
+ : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`;
useTerminalFocusReporting(stdout, !disabled);
useTerminalExtendedKeys(stdout, !disabled);
+ useBracketedPaste(stdout, !disabled);
useHiddenTerminalCursor(stdout, !disabled);
const refreshFileMentionItems = React.useCallback(() => {
@@ -241,6 +268,8 @@ export const PromptInput = React.memo(function PromptInput({
setHistoryCursor(-1);
setDraftBeforeHistory(null);
clearPromptUndoRedoState(undoRedoRef.current);
+ pastesRef.current.clear();
+ expandedRegionsRef.current.clear();
}, [promptDraft]);
useEffect(() => {
@@ -278,7 +307,7 @@ export const PromptInput = React.memo(function PromptInput({
if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) {
onToggleProcessStdout();
} else {
- setStatusMessage("No running process to inspect");
+ expandPasteMarkerAtCursor();
}
return;
}
@@ -306,6 +335,8 @@ export const PromptInput = React.memo(function PromptInput({
} else if (!isEmpty(buffer)) {
setBuffer(EMPTY_BUFFER);
clearUndoRedoStacks();
+ pastesRef.current.clear();
+ expandedRegionsRef.current.clear();
} else {
setStatusMessage("press ctrl+d to exit");
}
@@ -324,6 +355,11 @@ export const PromptInput = React.memo(function PromptInput({
exitHistoryBrowsing();
}
+ if (key.paste) {
+ handlePaste(input);
+ return;
+ }
+
if (key.ctrl && (input === "v" || input === "V")) {
setStatusMessage("Reading clipboard...");
readClipboardImageAsync()
@@ -395,12 +431,12 @@ export const PromptInput = React.memo(function PromptInput({
}
if (key.delete) {
- updateBuffer((s) => deleteForward(s));
+ updateBuffer((s) => deletePasteMarkerForward(s) ?? deleteForward(s));
return;
}
if (key.backspace) {
- updateBuffer((s) => backspace(s));
+ updateBuffer((s) => deletePasteMarkerBackward(s) ?? backspace(s));
return;
}
@@ -490,6 +526,8 @@ export const PromptInput = React.memo(function PromptInput({
}
if (key.ctrl && (input === "u" || input === "U")) {
updateBuffer(() => EMPTY_BUFFER);
+ pastesRef.current.clear();
+ expandedRegionsRef.current.clear();
return;
}
if (key.ctrl && (input === "w" || input === "W")) {
@@ -567,6 +605,81 @@ export const PromptInput = React.memo(function PromptInput({
});
}
+ function handlePaste(pastedText: string): void {
+ const totalChars = pastedText.length;
+
+ if (totalChars <= 1000) {
+ const newlineCount = (pastedText.match(/\n/g) ?? []).length;
+ if (newlineCount <= 9) {
+ const clean = pastedText
+ .replace(/\r\n|\r/g, "\n")
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
+ .replace(/\t/g, " ");
+ updateBuffer((s) => insertText(s, clean));
+ return;
+ }
+ }
+
+ // Large paste: store raw text, insert marker with line/char count.
+ const lineCount = (pastedText.match(/\n/g) ?? []).length + 1;
+ pasteCounterRef.current += 1;
+ const pasteId = pasteCounterRef.current;
+ pastesRef.current.set(pasteId, pastedText);
+
+ const marker =
+ lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`;
+
+ updateBuffer((s) => insertText(s, marker));
+ }
+
+ function expandPasteMarkerAtCursor(): void {
+ // First, try to collapse an already-expanded region at the cursor.
+ for (const [id, region] of expandedRegionsRef.current) {
+ if (buffer.cursor >= region.start && buffer.cursor <= region.end) {
+ // Collapse back to marker.
+ expandedRegionsRef.current.delete(id);
+ pastesRef.current.set(id, region.content);
+ setTimeout(() => {
+ updateBuffer((s) => {
+ const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end);
+ return { text, cursor: region.start + region.marker.length };
+ });
+ }, 0);
+ return;
+ }
+ }
+
+ // No expanded region at cursor — try to expand a paste marker.
+ const marker = findPasteMarkerContaining(buffer);
+ if (!marker) {
+ setStatusMessage("No paste marker at cursor");
+ return;
+ }
+ const content = pastesRef.current.get(marker.id);
+ if (!content) {
+ setStatusMessage("Paste content not found");
+ return;
+ }
+
+ const pasteId = marker.id;
+ const originalMarker = buffer.text.slice(marker.start, marker.end);
+ pastesRef.current.delete(pasteId);
+
+ setTimeout(() => {
+ updateBuffer((s) => {
+ const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end);
+ const newEnd = marker.start + content.length;
+ expandedRegionsRef.current.set(pasteId, {
+ start: marker.start,
+ end: newEnd,
+ content,
+ marker: originalMarker,
+ });
+ return { text, cursor: marker.start };
+ });
+ }, 0);
+ }
+
function navigateHistory(direction: -1 | 1): void {
if (promptHistory.length === 0) {
return;
@@ -607,6 +720,9 @@ export const PromptInput = React.memo(function PromptInput({
setImageUrls([]);
setSelectedSkills([]);
setShowSkillsDropdown(false);
+ pastesRef.current.clear();
+ expandedRegionsRef.current.clear();
+ pasteCounterRef.current = 0;
}
function handleSlashSelection(item: SlashCommandItem): void {
@@ -664,6 +780,8 @@ export const PromptInput = React.memo(function PromptInput({
setImageUrls([]);
setSelectedSkills([]);
setShowSkillsDropdown(false);
+ pastesRef.current.clear();
+ expandedRegionsRef.current.clear();
return;
}
if (item.kind === "mcp") {
@@ -699,7 +817,7 @@ export const PromptInput = React.memo(function PromptInput({
}
onSubmit({
- text: buffer.text,
+ text: expandPasteMarkers(buffer.text, pastesRef.current),
imageUrls,
selectedSkills,
});
@@ -871,9 +989,6 @@ export function getPromptReturnKeyAction(key: Pick= end) return "";
+
+ const segText = text.slice(start, end);
+ const cursorRel = cursor - start; // relative cursor position inside this segment
+
+ // Cursor not in this segment – just return the text.
+ if (cursorRel < 0 || cursorRel > segText.length) {
+ return highlighted ? chalk.yellow(segText) : segText;
}
- if (typeof at === "undefined") {
- return before + renderCursorCell(" ");
+ // Cursor is exactly at `end` (which equals `segText.length`).
+ if (cursorRel === segText.length) {
+ return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" ");
}
+
+ // Cursor is somewhere inside the segment.
+ const at = segText[cursorRel];
+
if (at === "\n") {
+ // Render newline as a space in the cursor cell, then output the actual newline.
+ const before = segText.slice(0, cursorRel);
+ const after = segText.slice(cursorRel + 1);
return before + renderCursorCell(" ") + "\n" + after;
}
+
+ const before = segText.slice(0, cursorRel);
+ const after = segText.slice(cursorRel + 1);
+ if (highlighted) {
+ return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after);
+ }
return before + renderCursorCell(at) + after;
}
diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts
index 2668470..aefea34 100644
--- a/src/ui/prompt/cursor.ts
+++ b/src/ui/prompt/cursor.ts
@@ -40,6 +40,14 @@ function disableTerminalFocusReporting(): string {
return "\u001B[?1004l";
}
+function enableBracketedPaste(): string {
+ return "\u001B[?2004h";
+}
+
+function disableBracketedPaste(): string {
+ return "\u001B[?2004l";
+}
+
export function enableTerminalExtendedKeys(): string {
return "\u001B[>4;1m";
}
@@ -260,3 +268,16 @@ export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined,
};
}, [isActive, stdout]);
}
+
+export function useBracketedPaste(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void {
+ useLayoutEffect(() => {
+ if (!isActive || !stdout?.isTTY) {
+ return;
+ }
+
+ stdout.write(enableBracketedPaste());
+ return () => {
+ stdout.write(disableBracketedPaste());
+ };
+ }, [isActive, stdout]);
+}
diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts
index 5907558..6435f62 100644
--- a/src/ui/prompt/index.ts
+++ b/src/ui/prompt/index.ts
@@ -4,6 +4,7 @@ export type { InputKey } from "./useTerminalInput";
export {
useHiddenTerminalCursor,
useTerminalExtendedKeys,
+ useBracketedPaste,
usePromptTerminalCursor,
useTerminalFocusReporting,
getPromptCursorPlacement,
diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts
index 9ce6976..e3d6349 100644
--- a/src/ui/prompt/useTerminalInput.ts
+++ b/src/ui/prompt/useTerminalInput.ts
@@ -20,6 +20,8 @@ export type InputKey = {
meta: boolean;
focusIn: boolean;
focusOut: boolean;
+ /** True when the input came from a bracketed paste (ESC[200~ ... ESC[201~). */
+ paste: boolean;
};
const BACKSPACE_BYTES = new Set(["\u007F", "\b"]);
@@ -35,6 +37,13 @@ const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]);
const TERMINAL_FOCUS_IN = "\u001B[I";
const TERMINAL_FOCUS_OUT = "\u001B[O";
+// Bracketed paste mode markers (xterm-style).
+// When the terminal supports bracketed paste, pasted text is wrapped with:
+// ESC[200~ ...pasted content... ESC[201~
+const PASTE_START = "\u001B[200~";
+const PASTE_END = "\u001B[201~";
+const PASTE_END_LENGTH = 6; // length of PASTE_END
+
// Ctrl+- (minus) sequences in modifyOtherKeys mode.
// \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl)
// \u001B[27;5;45~ — extended format for function-like reporting
@@ -73,6 +82,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
meta: false,
focusIn: false,
focusOut: false,
+ paste: false,
};
return { input, key };
}
@@ -100,6 +110,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
meta: false,
focusIn: false,
focusOut: false,
+ paste: false,
};
return { input, key };
}
@@ -123,6 +134,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw),
focusIn: raw === TERMINAL_FOCUS_IN,
focusOut: raw === TERMINAL_FOCUS_OUT,
+ paste: false,
};
if (input <= "\u001A" && !key.return) {
@@ -200,6 +212,29 @@ export function dispatchTerminalInput(
inputHandler(input, key);
}
+/** An InputKey with all fields false (including paste). Used when dispatching paste events. */
+const EMPTY_KEY: InputKey = {
+ upArrow: false,
+ downArrow: false,
+ leftArrow: false,
+ rightArrow: false,
+ home: false,
+ end: false,
+ pageDown: false,
+ pageUp: false,
+ return: false,
+ escape: false,
+ ctrl: false,
+ shift: false,
+ tab: false,
+ backspace: false,
+ delete: false,
+ meta: false,
+ focusIn: false,
+ focusOut: false,
+ paste: false,
+};
+
export function useTerminalInput(
inputHandler: (input: string, key: InputKey) => void,
options: { isActive?: boolean } = {}
@@ -209,8 +244,15 @@ export function useTerminalInput(
const handlerRef = useRef(inputHandler);
handlerRef.current = inputHandler;
+ // Mutable paste-bracketing state shared across data events.
+ // Uses an array of chunks instead of string concatenation to avoid
+ // O(n²) copying when the terminal splits a large paste across many events.
+ const pasteRef = useRef({ active: false, chunks: [] as string[] });
+
useEffect(() => {
if (!isActive) {
+ pasteRef.current.active = false;
+ pasteRef.current.chunks = [];
return;
}
setRawMode(true);
@@ -223,7 +265,75 @@ export function useTerminalInput(
if (!isActive) {
return;
}
+
const handleData = (data: Buffer | string) => {
+ const raw = String(data);
+
+ // ----- Bracketed paste handling -----
+ // Most terminals send the start/end markers in the same chunk as
+ // the content. We handle both inline and multi-chunk scenarios.
+
+ if (raw.includes(PASTE_START)) {
+ pasteRef.current.active = true;
+ pasteRef.current.chunks = [];
+
+ // Extract content after the start marker.
+ const startIdx = raw.indexOf(PASTE_START);
+ const afterStart = raw.slice(startIdx + PASTE_START.length);
+
+ // Check if the end marker is also in this same chunk.
+ const endIdx = afterStart.indexOf(PASTE_END);
+ if (endIdx !== -1) {
+ // Both markers in one chunk — process immediately.
+ const pasteContent = afterStart.slice(0, endIdx);
+ pasteRef.current.active = false;
+ const remaining = afterStart.slice(endIdx + PASTE_END_LENGTH);
+
+ if (pasteContent.length > 0) {
+ handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true });
+ }
+ if (remaining.length > 0) {
+ dispatchTerminalInput(remaining, handlerRef.current);
+ }
+ return;
+ }
+
+ // Only start marker — buffer as first chunk.
+ if (afterStart) {
+ pasteRef.current.chunks.push(afterStart);
+ }
+ return;
+ }
+
+ if (pasteRef.current.active) {
+ pasteRef.current.chunks.push(raw);
+ // Only join+search when this chunk might contain the end marker.
+ if (raw.includes("201~")) {
+ const combined = pasteRef.current.chunks.join("");
+ const endIdx = combined.indexOf(PASTE_END);
+ if (endIdx !== -1) {
+ const pasteContent = combined.slice(0, endIdx);
+ pasteRef.current.active = false;
+ const remaining = combined.slice(endIdx + PASTE_END_LENGTH);
+ pasteRef.current.chunks = [];
+
+ // Dispatch the pasted text as a single event.
+ if (pasteContent.length > 0) {
+ handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true });
+ }
+
+ // Handle any remaining input after the paste end marker.
+ if (remaining.length > 0) {
+ dispatchTerminalInput(remaining, handlerRef.current);
+ }
+ return;
+ }
+ return;
+ }
+ return;
+ }
+
+ // ----- Normal (non-paste) input -----
dispatchTerminalInput(data, handlerRef.current);
};
diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts
index 3e3c182..97d15a5 100644
--- a/src/ui/promptBuffer.ts
+++ b/src/ui/promptBuffer.ts
@@ -171,6 +171,120 @@ export function getCurrentSlashToken(state: PromptBufferState): string | null {
return line;
}
+/**
+ * Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`.
+ * When the user pastes a large block of text (>10 lines or >1000 chars), a compact
+ * marker is inserted instead of the full content. The actual content is stored in a
+ * Map and expanded back before submission.
+ */
+export const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+?\d+ lines|\d+ chars))?\]/g;
+
+/**
+ * Find the paste marker that ends exactly at `state.cursor`, if any.
+ * Returns the marker's start and end positions, or `null`.
+ */
+export function findPasteMarkerBefore(state: PromptBufferState): { start: number; end: number } | null {
+ // Walk backwards through all markers and return the one that ends at the cursor.
+ let match: RegExpExecArray | null;
+ PASTE_MARKER_REGEX.lastIndex = 0;
+ while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) {
+ if (match.index + match[0].length === state.cursor) {
+ return { start: match.index, end: match.index + match[0].length };
+ }
+ }
+ return null;
+}
+
+/**
+ * Find the paste marker that starts exactly at `state.cursor`, if any.
+ * Returns the marker's start and end positions, or `null`.
+ */
+export function findPasteMarkerAt(state: PromptBufferState): { start: number; end: number } | null {
+ let match: RegExpExecArray | null;
+ PASTE_MARKER_REGEX.lastIndex = 0;
+ while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) {
+ if (match.index === state.cursor) {
+ return { start: match.index, end: match.index + match[0].length };
+ }
+ }
+ return null;
+}
+
+/**
+ * If the cursor is immediately after a paste marker, delete the entire marker
+ * (atomic backspace). Returns the new state, or `state` unchanged if no marker.
+ */
+export function deletePasteMarkerBackward(state: PromptBufferState): PromptBufferState | null {
+ const marker = findPasteMarkerBefore(state);
+ if (!marker) return null;
+ const text = state.text.slice(0, marker.start) + state.text.slice(marker.end);
+ return { text, cursor: marker.start };
+}
+
+/**
+ * If the cursor is at the start of a paste marker, delete the entire marker
+ * (atomic forward delete). Returns the new state, or `state` unchanged if no marker.
+ */
+export function deletePasteMarkerForward(state: PromptBufferState): PromptBufferState | null {
+ const marker = findPasteMarkerAt(state);
+ if (!marker) return null;
+ const text = state.text.slice(0, marker.start) + state.text.slice(marker.end);
+ return { text, cursor: marker.start };
+}
+
+/**
+ * Sanitize stored paste content (filter control chars, expand tabs).
+ * Called lazily on expand/submit, not during paste to keep paste instant.
+ */
+export function cleanPasteContent(text: string): string {
+ return text
+ .replace(/\r\n|\r/g, "\n")
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
+ .replace(/\t/g, " ");
+}
+
+/**
+ * Expand paste markers in the text back to their original (cleaned) content.
+ * @param text - Text potentially containing paste markers.
+ * @param pastes - Map of paste ID → original content.
+ */
+export function expandPasteMarkers(text: string, pastes: Map): string {
+ if (pastes.size === 0) return text;
+ let result = text;
+ for (const [pasteId, pasteContent] of pastes) {
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+?\\d+ lines|\\d+ chars))?\\]`, "g");
+ result = result.replace(markerRegex, () => cleanPasteContent(pasteContent));
+ }
+ return result;
+}
+
+/**
+ * Find the paste marker that contains `state.cursor`, if any.
+ * Returns the marker's start, end, and numeric paste ID, or `null`.
+ */
+export function findPasteMarkerContaining(state: PromptBufferState): { start: number; end: number; id: number } | null {
+ let match: RegExpExecArray | null;
+ PASTE_MARKER_REGEX.lastIndex = 0;
+ while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) {
+ if (match.index <= state.cursor && match.index + match[0].length >= state.cursor) {
+ return {
+ start: match.index,
+ end: match.index + match[0].length,
+ id: Number.parseInt(match[1]!, 10),
+ };
+ }
+ }
+ return null;
+}
+
+/**
+ * Check whether the given text contains any paste markers.
+ */
+export function hasActivePasteMarkers(text: string): boolean {
+ PASTE_MARKER_REGEX.lastIndex = 0;
+ return PASTE_MARKER_REGEX.test(text);
+}
+
function locate(state: PromptBufferState): {
line: number;
column: number;
From bb95daff1995dabf191e7888909423e5018e71a3 Mon Sep 17 00:00:00 2001
From: Kayro
Date: Wed, 20 May 2026 22:30:59 +0800
Subject: [PATCH 69/95] fix: validate paste markers by ID to prevent false
positives
- hasActivePasteMarkers now checks validIds map, not just regex match
- deletePasteMarkerBackward/Forward only atomically delete real paste markers
- renderBufferWithCursor and renderFocusedText only highlight markers with valid IDs
- PASTE_MARKER_REGEX requires line/char suffix (no bare [paste #N])
- Fix empty buffer cursor rendering in renderFocusedText regression
- All render/test call sites updated to pass pastesRef.current
---
src/ui/PromptInput.tsx | 42 +++++++++++++++++++++++++-----------------
src/ui/promptBuffer.ts | 35 ++++++++++++++++++++++++++++-------
2 files changed, 53 insertions(+), 24 deletions(-)
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index ad73d83..0eaa169 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -188,7 +188,7 @@ export const PromptInput = React.memo(function PromptInput({
const showMenu = slashMenu.length > 0;
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
const hasRunningProcess = runningProcesses && runningProcesses.size > 0;
- const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text);
+ const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current);
const hasExpandedRegions = expandedRegionsRef.current.size > 0;
const processOrPasteHint = hasRunningProcess
? " · ctrl+o view output"
@@ -431,12 +431,12 @@ export const PromptInput = React.memo(function PromptInput({
}
if (key.delete) {
- updateBuffer((s) => deletePasteMarkerForward(s) ?? deleteForward(s));
+ updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s));
return;
}
if (key.backspace) {
- updateBuffer((s) => deletePasteMarkerBackward(s) ?? backspace(s));
+ updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s));
return;
}
@@ -872,7 +872,7 @@ export const PromptInput = React.memo(function PromptInput({
borderDimColor
>
- {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)}
+ {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)}
{inlineHint ? {inlineHint} : null}
+): string {
const text = state.text || "";
const cursor = Math.max(0, Math.min(state.cursor, text.length));
+ const validIds = validPastes ?? new Map();
if (text.length === 0 && placeholder) {
if (!isFocused) {
@@ -997,18 +1003,18 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool
return renderCursorCell(" ") + chalk.dim(` ${placeholder}`);
}
+ if (text.length === 0) {
+ return isFocused ? renderCursorCell(" ") : "";
+ }
+
if (!isFocused) {
- return highlightPasteMarkersInText(text);
+ return highlightPasteMarkersInText(text, validIds);
}
- // Focused: scan through the text, highlight paste markers, and insert
- // the cursor cell at the correct position. This approach handles the
- // case where the cursor sits at the start of (or inside) a paste marker.
- return renderFocusedText(text, cursor);
+ return renderFocusedText(text, cursor, validIds);
}
-/** Highlight paste markers in a plain string (no cursor). */
-function highlightPasteMarkersInText(s: string): string {
+function highlightPasteMarkersInText(s: string, validIds: Map): string {
if (!s.includes("[paste #")) return s;
PASTE_MARKER_REGEX.lastIndex = 0;
let result = "";
@@ -1016,7 +1022,8 @@ function highlightPasteMarkersInText(s: string): string {
let match: RegExpExecArray | null;
while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) {
result += s.slice(pos, match.index);
- result += chalk.yellow(match[0]);
+ const id = Number.parseInt(match[1]!, 10);
+ result += validIds.has(id) ? chalk.yellow(match[0]) : match[0];
pos = match.index + match[0].length;
}
result += s.slice(pos);
@@ -1029,7 +1036,7 @@ function highlightPasteMarkersInText(s: string): string {
* anywhere (including inside or at the boundary of a paste marker) and the
* marker will still be highlighted correctly.
*/
-function renderFocusedText(text: string, cursor: number): string {
+function renderFocusedText(text: string, cursor: number, validIds: Map): string {
let result = "";
let pos = 0;
PASTE_MARKER_REGEX.lastIndex = 0;
@@ -1038,14 +1045,15 @@ function renderFocusedText(text: string, cursor: number): string {
while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) {
const markerStart = match.index;
const markerEnd = match.index + match[0].length;
+ const id = Number.parseInt(match[1]!, 10);
+ const isReal = validIds.has(id);
// 1. Non-marker segment before this marker.
result += renderTextSegmentWithCursor(text, pos, markerStart, cursor, false);
pos = markerStart;
- // 2. Marker segment — highlighted with chalk.yellow.
- // The cursor may fall inside it.
- result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, true);
+ // 2. Marker segment — highlighted only if it corresponds to a real paste.
+ result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, isReal);
pos = markerEnd;
}
diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts
index 97d15a5..3e0a710b 100644
--- a/src/ui/promptBuffer.ts
+++ b/src/ui/promptBuffer.ts
@@ -177,7 +177,7 @@ export function getCurrentSlashToken(state: PromptBufferState): string | null {
* marker is inserted instead of the full content. The actual content is stored in a
* Map and expanded back before submission.
*/
-export const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+?\d+ lines|\d+ chars))?\]/g;
+export const PASTE_MARKER_REGEX = /\[paste #(\d+) (\+?\d+ lines|\d+ chars)\]/g;
/**
* Find the paste marker that ends exactly at `state.cursor`, if any.
@@ -214,9 +214,16 @@ export function findPasteMarkerAt(state: PromptBufferState): { start: number; en
* If the cursor is immediately after a paste marker, delete the entire marker
* (atomic backspace). Returns the new state, or `state` unchanged if no marker.
*/
-export function deletePasteMarkerBackward(state: PromptBufferState): PromptBufferState | null {
+export function deletePasteMarkerBackward(
+ state: PromptBufferState,
+ validIds: Map
+): PromptBufferState | null {
const marker = findPasteMarkerBefore(state);
if (!marker) return null;
+ // Only delete if this is a real paste marker (ID in validIds).
+ PASTE_MARKER_REGEX.lastIndex = 0;
+ const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end));
+ if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null;
const text = state.text.slice(0, marker.start) + state.text.slice(marker.end);
return { text, cursor: marker.start };
}
@@ -225,9 +232,16 @@ export function deletePasteMarkerBackward(state: PromptBufferState): PromptBuffe
* If the cursor is at the start of a paste marker, delete the entire marker
* (atomic forward delete). Returns the new state, or `state` unchanged if no marker.
*/
-export function deletePasteMarkerForward(state: PromptBufferState): PromptBufferState | null {
+export function deletePasteMarkerForward(
+ state: PromptBufferState,
+ validIds: Map
+): PromptBufferState | null {
const marker = findPasteMarkerAt(state);
if (!marker) return null;
+ // Only delete if this is a real paste marker (ID in validIds).
+ PASTE_MARKER_REGEX.lastIndex = 0;
+ const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end));
+ if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null;
const text = state.text.slice(0, marker.start) + state.text.slice(marker.end);
return { text, cursor: marker.start };
}
@@ -252,7 +266,7 @@ export function expandPasteMarkers(text: string, pastes: Map): s
if (pastes.size === 0) return text;
let result = text;
for (const [pasteId, pasteContent] of pastes) {
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+?\\d+ lines|\\d+ chars))?\\]`, "g");
+ const markerRegex = new RegExp(`\\[paste #${pasteId} (\\+?\\d+ lines|\\d+ chars)\\]`, "g");
result = result.replace(markerRegex, () => cleanPasteContent(pasteContent));
}
return result;
@@ -278,11 +292,18 @@ export function findPasteMarkerContaining(state: PromptBufferState): { start: nu
}
/**
- * Check whether the given text contains any paste markers.
+ * Check whether the text contains real paste markers (IDs present in validIds).
*/
-export function hasActivePasteMarkers(text: string): boolean {
+export function hasActivePasteMarkers(text: string, validIds: Map): boolean {
+ if (!text.includes("[paste #")) return false;
PASTE_MARKER_REGEX.lastIndex = 0;
- return PASTE_MARKER_REGEX.test(text);
+ let match: RegExpExecArray | null;
+ while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) {
+ if (validIds.has(Number.parseInt(match[1]!, 10))) {
+ return true;
+ }
+ }
+ return false;
}
function locate(state: PromptBufferState): {
From 27fcfd02e0c8cf564f39fa8d3fc4c9a878dbe501 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Thu, 21 May 2026 10:36:19 +0800
Subject: [PATCH 70/95] feat: add normalizeLlmToolCalls() to replace
missing/empty tool call IDs
---
src/session.ts | 34 ++++++++++++++++++++--
src/tests/session.test.ts | 60 +++++++++++++++++++++++++++++++++++++++
2 files changed, 91 insertions(+), 3 deletions(-)
diff --git a/src/session.ts b/src/session.ts
index b4a3c71..54340e7 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -570,9 +570,10 @@ export class SessionManager {
const toolCalls = Array.from(toolCallsByIndex.entries())
.sort(([left], [right]) => left - right)
.map(([, toolCall]) => toolCall);
+ const normalizedToolCalls = this.normalizeLlmToolCalls(toolCalls);
const message: Record = { content };
- if (toolCalls.length > 0) {
- message.tool_calls = toolCalls;
+ if (normalizedToolCalls) {
+ message.tool_calls = normalizedToolCalls;
}
if (reasoningContent.length > 0) {
message.reasoning_content = reasoningContent;
@@ -1180,7 +1181,7 @@ ${skillMd}
const rawContent = message?.content;
const content = typeof rawContent === "string" ? rawContent : "";
const rawToolCalls = (message as { tool_calls?: unknown[] } | undefined)?.tool_calls ?? null;
- toolCalls = Array.isArray(rawToolCalls) && rawToolCalls.length > 0 ? rawToolCalls : null;
+ toolCalls = this.normalizeLlmToolCalls(rawToolCalls);
const rawThinking = (message as { reasoning_content?: unknown } | undefined)?.reasoning_content;
const thinking = typeof rawThinking === "string" ? rawThinking : null;
const refusal = (message as { refusal?: string } | undefined)?.refusal ?? null;
@@ -1899,6 +1900,33 @@ ${skillMd}
};
}
+ private generateToolCallId(): string {
+ return crypto.randomBytes(16).toString("hex");
+ }
+
+ private normalizeLlmToolCalls(rawToolCalls: unknown[] | null | undefined): unknown[] | null {
+ if (!Array.isArray(rawToolCalls) || rawToolCalls.length === 0) {
+ return null;
+ }
+
+ return rawToolCalls.map((toolCall) => {
+ if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) {
+ return toolCall;
+ }
+
+ const record = toolCall as Record;
+ const id = typeof record.id === "string" ? record.id.trim() : "";
+ if (id) {
+ return toolCall;
+ }
+
+ return {
+ ...record,
+ id: this.generateToolCallId(),
+ };
+ });
+ }
+
private buildToolMessage(
sessionId: string,
toolCallId: string,
diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts
index 9f3c7fb..fd83199 100644
--- a/src/tests/session.test.ts
+++ b/src/tests/session.test.ts
@@ -1565,6 +1565,66 @@ test("Write tool params prefer file_path even when content appears first", () =>
assert.equal(toolMessage.meta?.paramsMd, filePath);
});
+test("LLM tool calls without ids receive generated 32 character ids", async () => {
+ const workspace = createTempDir("deepcode-tool-call-id-workspace-");
+ const home = createTempDir("deepcode-tool-call-id-home-");
+ setHomeDir(home);
+
+ const filePath = path.join(workspace, "note.txt");
+ fs.writeFileSync(filePath, "hello\n", "utf8");
+ const plan = "## Task List\n\n- [ ] Inspect current behavior";
+ const manager = createMockedClientSessionManager(workspace, [
+ {
+ choices: [
+ {
+ message: {
+ content: "",
+ tool_calls: [
+ {
+ id: "",
+ type: "function",
+ function: {
+ name: "UpdatePlan",
+ arguments: JSON.stringify({ plan, explanation: "Initial plan" }),
+ },
+ },
+ {
+ type: "function",
+ function: {
+ name: "read",
+ arguments: JSON.stringify({ file_path: filePath }),
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ ]);
+
+ const sessionId = await manager.createSession({ text: "inspect note" });
+ const assistantMessage = manager
+ .listSessionMessages(sessionId)
+ .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls);
+ const toolCalls = (assistantMessage?.messageParams as { tool_calls?: Array<{ id?: unknown }> } | null)?.tool_calls;
+
+ assert.equal(toolCalls?.length, 2);
+ assert.match(String(toolCalls?.[0]?.id), /^[0-9a-f]{32}$/);
+ assert.match(String(toolCalls?.[1]?.id), /^[0-9a-f]{32}$/);
+ assert.notEqual(toolCalls?.[0]?.id, toolCalls?.[1]?.id);
+
+ const toolMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "tool");
+ assert.deepEqual(
+ toolMessages.map((message) => (message.messageParams as { tool_call_id?: unknown } | null)?.tool_call_id),
+ toolCalls?.map((toolCall) => toolCall.id)
+ );
+
+ const readToolMessage = toolMessages.find((message) => JSON.parse(message.content ?? "{}").name === "read");
+ assert.equal((readToolMessage?.meta?.function as { name?: string } | undefined)?.name, "read");
+ assert.equal(readToolMessage?.meta?.paramsMd, "note.txt");
+});
+
test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => {
const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase");
const assistantMessage = (manager as any).buildAssistantMessage(
From 5b51f4066c09764eb1de631109166a8d69013a94 Mon Sep 17 00:00:00 2001
From: Ji Zhang
Date: Thu, 21 May 2026 10:40:31 +0800
Subject: [PATCH 71/95] 0.1.24
---
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 17a77ca..0250531 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@vegamo/deepcode-cli",
- "version": "0.1.23",
+ "version": "0.1.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vegamo/deepcode-cli",
- "version": "0.1.23",
+ "version": "0.1.24",
"license": "MIT",
"dependencies": {
"chalk": "^5.6.2",
diff --git a/package.json b/package.json
index b72fd96..c8809ea 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@vegamo/deepcode-cli",
- "version": "0.1.23",
+ "version": "0.1.24",
"description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal",
"license": "MIT",
"type": "module",
From ec9a219554858262687180b40f5726a133e3bf73 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Thu, 21 May 2026 14:41:24 +0800
Subject: [PATCH 72/95] =?UTF-8?q?docs(cli):=20=E6=9B=B4=E6=96=B0=E5=B9=B6?=
=?UTF-8?q?=E6=89=A9=E5=B1=95=E5=91=BD=E4=BB=A4=E8=8F=9C=E5=8D=95=E8=AF=B4?=
=?UTF-8?q?=E6=98=8E=E5=92=8C=E5=B8=AE=E5=8A=A9=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 /skills、/model、/undo、/mcp 和 /raw 命令说明
- README.md 和 README-en.md 中同步添加对应命令描述
- 详细列出各命令功能,方便用户快速查阅
- 修改 CLI 界面帮助输出,包含所有新增命令提示
- 优化菜单结构,提升用户操作体验
---
README-en.md | 8 ++++++++
README-zh_CN.md | 45 ++++++++++++++++++++++++++-------------------
README.md | 45 ++++++++++++++++++++++++++-------------------
src/cli.tsx | 5 +++++
4 files changed, 65 insertions(+), 38 deletions(-)
diff --git a/README-en.md b/README-en.md
index 55d0cf6..d9719fd 100644
--- a/README-en.md
+++ b/README-en.md
@@ -66,11 +66,13 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap
| `/` | Open the skills / commands menu |
| `/new` | Start a fresh conversation |
| `/resume` | Choose a previous conversation to continue |
+| `/continue` | Continue the active conversation or pick one to resume |
| `/model` | Switch model, thinking mode, and reasoning effort |
| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) |
| `/init` | Initialize an AGENTS.md file (LLM project instructions) |
| `/skills` | List available skills |
| `/mcp` | View MCP server status and available tools |
+| `/undo` | Restore code and/or conversation to a previous point |
| `/exit` | Quit (also `Ctrl+D` twice) |
| Key | Action |
@@ -126,6 +128,12 @@ Deep Code supports MCP (Model Context Protocol) to connect external services suc
For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md)
+### How to configure Deep Code to send notifications after a task completes?
+
+When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the task results to the specified channel (e.g., Slack, system notifications, etc.).
+
+For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md)
+
## Contributing
Contributions are welcome! Here's how to get started:
diff --git a/README-zh_CN.md b/README-zh_CN.md
index 8a427de..7b74a50 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -60,25 +60,27 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力:
## 斜杠命令与按键功能
-| 斜杠命令 | 操作 |
-|-----------------|---------------------------------------------|
-| `/` | 打开 skills / 命令菜单 |
-| `/new` | 开始新对话 |
-| `/resume` | 选择历史对话继续 |
-| `/model` | 切换模型、思考模式和推理强度 |
-| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯)|
-| `/init` | 初始化 AGENTS.md 文件 |
-| `/skills` | 列出可用 skills |
-| `/mcp` | 查看 MCP 服务器状态和可用工具 |
-| `/exit` | 退出(也可用连续 `Ctrl+D`) |
-
-| 按键 | 操作 |
-|-----------------|---------------------------------------------|
-| `Enter` | 发送消息 |
-| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
-| `Ctrl+V` | 从剪贴板粘贴图片 |
-| `Esc` | 中断当前模型回复 |
-| 连续 `Ctrl+D` | 退出 |
+| 斜杠命令 | 操作 |
+|-------------|----------------------------------|
+| `/` | 打开 skills / 命令菜单 |
+| `/new` | 开始新对话 |
+| `/resume` | 选择历史对话继续 |
+| `/continue` | 继续当前对话,或选择历史对话恢复 |
+| `/model` | 切换模型、思考模式和推理强度 |
+| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |
+| `/init` | 初始化 AGENTS.md 文件 |
+| `/skills` | 列出可用 skills |
+| `/mcp` | 查看 MCP 服务器状态和可用工具 |
+| `/undo` | 将代码和/或对话恢复到之前的状态 |
+| `/exit` | 退出(也可用连续 `Ctrl+D`) |
+
+| 按键 | 操作 |
+|---------------|--------------------|
+| `Enter` | 发送消息 |
+| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
+| `Ctrl+V` | 从剪贴板粘贴图片 |
+| `Esc` | 中断当前模型回复 |
+| 连续 `Ctrl+D` | 退出 |
## 支持的模型
@@ -111,6 +113,11 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览
详细配置指南:[docs/mcp.md](docs/mcp.md)
+### 如何配置 Deep Code 任务完成后发送通知?
+
+当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。
+
+详细配置指南:[docs/notify.md](docs/notify.md)
### 是否支持 Coding Plan?
diff --git a/README.md b/README.md
index 8a427de..7b74a50 100644
--- a/README.md
+++ b/README.md
@@ -60,25 +60,27 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力:
## 斜杠命令与按键功能
-| 斜杠命令 | 操作 |
-|-----------------|---------------------------------------------|
-| `/` | 打开 skills / 命令菜单 |
-| `/new` | 开始新对话 |
-| `/resume` | 选择历史对话继续 |
-| `/model` | 切换模型、思考模式和推理强度 |
-| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯)|
-| `/init` | 初始化 AGENTS.md 文件 |
-| `/skills` | 列出可用 skills |
-| `/mcp` | 查看 MCP 服务器状态和可用工具 |
-| `/exit` | 退出(也可用连续 `Ctrl+D`) |
-
-| 按键 | 操作 |
-|-----------------|---------------------------------------------|
-| `Enter` | 发送消息 |
-| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
-| `Ctrl+V` | 从剪贴板粘贴图片 |
-| `Esc` | 中断当前模型回复 |
-| 连续 `Ctrl+D` | 退出 |
+| 斜杠命令 | 操作 |
+|-------------|----------------------------------|
+| `/` | 打开 skills / 命令菜单 |
+| `/new` | 开始新对话 |
+| `/resume` | 选择历史对话继续 |
+| `/continue` | 继续当前对话,或选择历史对话恢复 |
+| `/model` | 切换模型、思考模式和推理强度 |
+| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |
+| `/init` | 初始化 AGENTS.md 文件 |
+| `/skills` | 列出可用 skills |
+| `/mcp` | 查看 MCP 服务器状态和可用工具 |
+| `/undo` | 将代码和/或对话恢复到之前的状态 |
+| `/exit` | 退出(也可用连续 `Ctrl+D`) |
+
+| 按键 | 操作 |
+|---------------|--------------------|
+| `Enter` | 发送消息 |
+| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
+| `Ctrl+V` | 从剪贴板粘贴图片 |
+| `Esc` | 中断当前模型回复 |
+| 连续 `Ctrl+D` | 退出 |
## 支持的模型
@@ -111,6 +113,11 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览
详细配置指南:[docs/mcp.md](docs/mcp.md)
+### 如何配置 Deep Code 任务完成后发送通知?
+
+当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。
+
+详细配置指南:[docs/notify.md](docs/notify.md)
### 是否支持 Coding Plan?
diff --git a/src/cli.tsx b/src/cli.tsx
index 66ceb7d..c3876ae 100644
--- a/src/cli.tsx
+++ b/src/cli.tsx
@@ -41,10 +41,15 @@ if (args.includes("--help") || args.includes("-h")) {
" ctrl+x Clear pasted images",
" esc Interrupt the current model turn",
" / Open the skills/commands menu",
+ " /skills List available skills",
+ " /model Select model, thinking mode and effort control",
" /new Start a fresh conversation",
" /init Initialize an AGENTS.md file with instructions for LLM",
" /resume Pick a previous conversation to continue",
" /continue Continue the active conversation, or resume one if empty",
+ " /undo Restore code and/or conversation to a previous point",
+ " /mcp Show MCP server status and available tools",
+ " /raw Toggle display mode for viewing or collapsing reasoning content",
" /exit Quit",
" ctrl+d twice Quit",
].join("\n") + "\n"
From 040a3245bbca60301c325ce6b993bf236ed8df4c Mon Sep 17 00:00:00 2001
From: hcyang
Date: Thu, 21 May 2026 14:43:08 +0800
Subject: [PATCH 73/95] =?UTF-8?q?docs(cli):=20=E6=9B=B4=E6=96=B0=E5=B9=B6?=
=?UTF-8?q?=E6=89=A9=E5=B1=95=E5=91=BD=E4=BB=A4=E8=8F=9C=E5=8D=95=E8=AF=B4?=
=?UTF-8?q?=E6=98=8E=E5=92=8C=E5=B8=AE=E5=8A=A9=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 /skills、/model、/undo、/mcp 和 /raw 命令说明
- README.md 和 README-en.md 中同步添加对应命令描述
- 详细列出各命令功能,方便用户快速查阅
- 修改 CLI 界面帮助输出,包含所有新增命令提示
- 优化菜单结构,提升用户操作体验
---
README-en.md | 26 +++++++++++++-------------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/README-en.md b/README-en.md
index d9719fd..18e3f13 100644
--- a/README-en.md
+++ b/README-en.md
@@ -61,19 +61,19 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap
## Slash Commands & Keyboard Shortcuts
-| Slash Command | Action |
-|------------------|----------------------------------------------------------|
-| `/` | Open the skills / commands menu |
-| `/new` | Start a fresh conversation |
-| `/resume` | Choose a previous conversation to continue |
-| `/continue` | Continue the active conversation or pick one to resume |
-| `/model` | Switch model, thinking mode, and reasoning effort |
-| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) |
-| `/init` | Initialize an AGENTS.md file (LLM project instructions) |
-| `/skills` | List available skills |
-| `/mcp` | View MCP server status and available tools |
-| `/undo` | Restore code and/or conversation to a previous point |
-| `/exit` | Quit (also `Ctrl+D` twice) |
+| Slash Command | Action |
+|------------------|---------------------------------------------------------|
+| `/` | Open the skills / commands menu |
+| `/new` | Start a fresh conversation |
+| `/resume` | Choose a previous conversation to continue |
+| `/continue` | Continue the active conversation or pick one to resume |
+| `/model` | Switch model, thinking mode, and reasoning effort |
+| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) |
+| `/init` | Initialize an AGENTS.md file (LLM project instructions) |
+| `/skills` | List available skills |
+| `/mcp` | View MCP server status and available tools |
+| `/undo` | Restore code and/or conversation to a previous point |
+| `/exit` | Quit (also `Ctrl+D` twice) |
| Key | Action |
|------------------|----------------------------------------------------------|
From 56e75050dce95fadf4bfde76feb80d703699a2a1 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Thu, 21 May 2026 15:37:55 +0800
Subject: [PATCH 74/95] =?UTF-8?q?docs(readme):=20=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=BE=BD=E7=AB=A0=E5=B1=95=E7=A4=BA=E6=8F=90?=
=?UTF-8?q?=E5=8D=87=E4=BF=A1=E6=81=AF=E5=8F=AF=E8=A7=81=E6=80=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 README.md、README-en.md 和 README-zh_CN.md 中添加了 npm 和 GitHub 相关徽章
- 新增版本号、下载量、贡献者、分支、Star、Issue、PR 和许可信息的动态展示链接
- 增强项目主页的视觉效果和信息传达
- 为中英文 README 文件同步更新相同内容及样式
---
README-en.md | 23 +++++++++++++++++++++++
README-zh_CN.md | 22 ++++++++++++++++++++++
README.md | 22 ++++++++++++++++++++++
3 files changed, 67 insertions(+)
diff --git a/README-en.md b/README-en.md
index 18e3f13..be6442b 100644
--- a/README-en.md
+++ b/README-en.md
@@ -8,6 +8,9 @@
Deep Code CLI
+[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link]
+[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link]
+
English · [中文](./README.md)
@@ -174,3 +177,23 @@ If you find this tool helpful, please consider supporting us by:
- Giving us a Star on GitHub (https://github.com/lessweb/deepcode-cli)
- Submitting feedback and suggestions
- Sharing with your friends and colleagues
+
+
+
+
+[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli
+[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square
+[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
+[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-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-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-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-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-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 7b74a50..52f0123 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -8,6 +8,9 @@
Deep Code CLI
+[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link]
+[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link]
+
[English](README-en.md) · 中文
@@ -173,3 +176,22 @@ npm link
- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli)
- 向我们提交反馈和建议
- 分享给你的朋友和同事
+
+
+
+[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli
+[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square
+[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
+[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-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-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-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-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-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 7b74a50..52f0123 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,9 @@
Deep Code CLI
+[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link]
+[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link]
+
[English](README-en.md) · 中文
@@ -173,3 +176,22 @@ npm link
- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli)
- 向我们提交反馈和建议
- 分享给你的朋友和同事
+
+
+
+[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli
+[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square
+[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
+[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-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-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-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-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-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square
\ No newline at end of file
From b010fb16f92dd01444d683566e41c3ef08c72af7 Mon Sep 17 00:00:00 2001
From: hcyang
Date: Thu, 21 May 2026 22:46:17 +0800
Subject: [PATCH 75/95] =?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 76/95] 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 77/95] 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 78/95] 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 79/95] 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 80/95] 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}
+${skill.name}-skill>`;
+ 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 81/95] 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 82/95] 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 83/95] 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 84/95] 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
zcmeFZcT`hbyFDCKY>3!^14^?X9i>VOD7}PUBs7)YMVbTx*cGHHz4ug2t`Z(ip7z{?O
ztR$xmgB=Wo!6=uG9tM9|6!QEDzEQYpE8cpjqqe_1)L#FG1QbyO5L_0Lx&60b;g8M@>T$0+{y`~Qt`Xp%)B
z5F=k2s&Al4W&Iin;yrV+_&7qGkgc}Roe%!g+nXz0Q%Qcz(s2c;@z}03MMc-i*6R&M
zl}^ImEVL6V7bG9v7jpW3!_{Z>%dygq1B8|%gjoh$6pa~P)V%(+&D^(Qtq}}5jZOCX
zmkx!l0;9@SkNJV}?1BPbUlH&0B5Sw0wnQljQkpa=t=r9{>OLEO_T2?spKHW|H9fzn
zL*XDCO_CFJ9)8`PAYl+i?A_WjP#IC&DR^}7NPUMKJNm^*rT{*Znsd@`6F@YMSaHRz0Gn(r^Y>bv?e%68#YJ
zm4Kz?)|b
z%Rh}sz4?Y`5itT8Ix)!hI`8#VLF@Ktf&Qpvk#SBpl)+zN^xbfP4H%k
z`}W3C@bkyKMip{ruc`|*C~vJVF1ne;Kg-8wl}YVxjODDCPvEYIN6pXAKT~BfxlN`1
zacr_J4&fLm@LDTPL7tbFx9d6e`PSJQ*3~K@!LF?e=MiRUEp97;H6pS;@YNt_{>X1TY`BIjCst3da0+A~Y#HD_54XW+xxGTfF>1?AX%loh
zYA>?+^Hj0yOe-#RJqc71KHMy%Yo5#>XlEy9LZ|yA1<{b&D`^rfpSjmjA)ec*P0}3<
zZ~ZG=-}3RHFql6|t0kT{ME7ucMMbQD2N=w*H&;}}MWnJGC>ITus7Ple@t1Vsqa2jA
z0>cqeiGAp23qidFM%K89_ZrA@y9oMq`jjkwFKixh+E<^@Ewxx?i*L;|&*(DE6Yq6R
z{gL13Ad`JK>dQkYih0TLP}~{?A*b{KsqFS@FygnIt?~yWt#yx2qpyc&Rfy5_nmJ
zAvKqEb3W<&8kvq%u)NTe)N-qrP`YQ0WDHlQj26-9jM898Inai3f7NW%n`Es)^>DSi
zQuvOcZ(@3pcsf556#WJXDifK_smIQ;M_*U2I1*ZJ-f+_OY_@=kDB6$>-BY}-N~+7S
z4IQfWj3XAA)(yC^>BjbyTw+wTUgRvfMap&E_;uHH#2^-9ArbwL2P)DkcnfisV|b
z%@X^aax+Gx6GVqvq9lr%J>6N1%ZObE4DUDd$dzA@*?tYI@e%jKx~Ry;=I|z>MX9K#eAF7Y)*-#?F3{I`W2u29u8zwb*Zy^9Yke%O{oH+(E}SBa
zz-f7+!cby!qGXP~+3JS8=Tv*bMmBeb^H_Zl)6I0dKFyfN&gC;TP8Igg_?672z-3Yq
zX5Vi{!k_Yrx!Nw9+L4F^tHs(*{8V0=Vu(k4V3e6}|D60IzboK8At3?JU?o3vHCr0P
zF15j%=$cofhei3f{Wccx7$o*bcxP2v{EMPb4)_gK+{
zIDSH@r5z!tqWP`_vnH>2hU(#O>-18ZC#BG9zB$H$l{MN?xDKD?rmK@x(^3_T!ssSt
z|K}%}7+KIQ!E9w<#7KKWf`!w5`ce8?p+$?m8%twtW7-{(E3fN?
z^``xWR!ADHkMB&>WjbcvgShjB;?tFGX^gyJ@wHi-d7kIuqO1}~tyZRg?+urnMQ8F&
ztNkfl@5PHG9c86zKlwm!WD0legs#1olV(|O>}ex&W8Dfr49?o#oU6au)%qvCJ1+c~
zA#(JJP&2wIJVL6dOx!1APOa83(Pz7;a3uxs5Q%lWq*4y82Vg_H~w!TXWudi+4VzX1V
zNDh;0-^@c|_;ozj7KYBVq=XlqrkrSw$7~HP)^|LALfi}@Zt@a|ubh;6@bb3qDd#27
z?1t?grW$Nn8n!siD2st^RBSxetd9BeJ-k5OR6(D+_LL$5qfl5YF!^P+sHjh`>lFzBt=CS_gDy!nNpTC0*8
zRHSrz(NuSa&N^MLS|9GRna|R*n0cv=xcgu;t?}!5yk*;$R8x>!u5J$Rky-~U!d=<3
z4(k_*uqSlF&_`&uvk>7i&&Ye|b=(WP`*j;&4F35l=p}YNqlM7gkEbvJO^>xq+$+yp
zr%|Nk`hOxjcS^m=#__pCHa4^`w;c!?sg#`8^A1J>Zuzao4Bm{ZS-o=zOn%f~z^uH(2SsE`X>+!|k7P7w8PTbgK;!e(`!Fo*p``&jyE!+|A#Ff6SB7Y)i3`V&K)xeS8z0
z;ppnAzZdv45%b=qPWJ^7oOLRgsP}@nnSeNl9mC|!VM(LE&x5$
z=0|6G^2Q9d_ow6*Dd06R4091gPL@VNrk$nxkel=LSzMpjC3M~AS=chzM!Q~}y(;e5
zOf0L07dg)M7A!gQFz<8-JQ!Chk2EtY`l}r2_t#&-=$ih>^P*1C
zd^LrGAD!4*KsE#O^E#5hk@OW$dtwyVFb2z7!*(-sawKWe{NsqbPrTjHuD;F({
zat_0cvY|&VqQAcMf4h=daVX~ZS0B|8087WxIAW#!e0|QH#7$Lpf~|!CGPErz>m
zO4n^@xxdCrc&{%6g#_>ui4#Gy8QeI73+Y9_zb4_biY<@7p(XmR*Ny}wQ6C{+3l)k^
zKOrAN1sCkERb^AXAhFB}yLUC-BLt`CTRWbyTyBZt4>9%IabNMP?NqZnc=*J%1}>qJ
zGvw&jmcsSXk`clF4>73pA|<6HFie)iheK;U7lT8%&I{RuHeGA!np4P9PY76>>pv^4
zbDr;p?vvTCQ2PAmt30z~kT2g>kT$~+0+TGkL&V$YJ>eV1CSu(J{W#niCb-WH+`VdO
zjrzL{hdk0%O%F;n`9|r&Z-%*0U|cPLtZ~aZcDY9-XG$@InF))hQK_xad@@2FR(fcU
z`PU0`&vrxE4YM&ybyj9!1Uuqj81m{6^ud}B8Ds|Z6&=$Qf&@DH8-^V1EY$`u=vu<3
z+*j@;j7I?8gu&RB1^l~FL9vGo20>`Hx~>%`=;ePmN5q=j@}$?xKv9j4`3fD$Drb5)
z6j{`z{>J>^t*wnA&yj+SjUOMMOuFpt?xbZU`3|_5@j6C8r-r%pyfbHA{9b+5@q5+v
zfihd2?GwY?m?CtDzsma)tmO*LvBLJP
zA~v0@EAeQ_dtY7#gn&V}W41pr{=M2Y&9WsjM&=B3xMoZg#rU^k^RyU2>sZHMKR!;1
zZGGdL)mm8k^6G4xxx``Z(keR!n#W4e!P%`Xuw{@?t&W{^^q|w
zEBVvDgAp^WacjyS%x)_@Xv^YR2?3+`BPxf(>{5t9CL_yh^Mgx*caM>C)Y5sv%0FJ*
zkuPFnV==EnTwO>++tNz&)Aqu)xOAHj{uJyt+^(aD(>Cq#Vwto$kFqmdBE)TvbJ&Nu
zlLFYuA^x9*(3atC+F#X&LsFAnz>-gRtk|`fE&Px?wV_@k2MmS0vNU+JNJa;boP3@1
zDm6~jSpgJTGiNSavyngZicvcMfAT8N41o!l8)iALFAP~>Jr{@hc++FgHMVobS%Hb{
zN|f>*RqO}#1<1lVCkdC(h8NL2Z0K};RR}kS5G`|hiC!bmgx!$O<>KnS*liK2-o>^D
zSS2=k+f}$@4fW0C0s&Y6%k~+OmI1u~Pa?+|Ts{<<7%N~I&_8>?sOtXRGK;1&rNW}3
zaj_>%o-U`c>@?1y&yttpzfxj)QKJ$IVV3$(C^Js2WQVjW*dV>xq>Lb4MJ9uhHgce1wgt+QBkD7cuvFOw3b`IA^L9Z(=#e7XMVN+>gd2q
zigYV(ZS2o{QI>dVKREoj&NQLrd|%)Qln(xWnYnnKmkDN)5Q!CYwVE{23c4VMJJdG7
zj3#Q~TcsSny|V$axFjK7TFRB9|M>_geVmQ?698OgL;u^?;h$)FeRQ%%eJZZ)q$I+-
z%}Lzlo}Y$RTY`irveu)ZAC%!eE-AdG@4wCQN@4GP4G3mPC^W9Vw?m*|Bw!ic6Zf-40NM(5H5L!0XUm3^@3_`f^+qDqMb$Euk6?={0S!U?r!yL
zO5y6N?42!?@7CJ92_1KV9(nLo-N7WboFU}>hj@YlvE&DZMR_=L+qZCeByN8KyXgse
z)Py{KoWXgVfmwQyNO}=Q$;{CX!P4^ZkQG=7?zP_nSpTzCQ{;JC21+Zi_|9iNKi75B
zh7rRvvISSvNNe-wsTl+=`-%iO4b@my0G2@B(a~|Kw4Z7}&Z$A1mb0SEL7W%SL8lBM
z_N4X_Ps^ar$jt1;3s|aj7nxxyPeKI~
zS5FYO=m@jb0He|C(_FZ5|HGl`ra%O}-T*2xb)B#iTi*0LBK{GlWDt;=Oo;;*ercAG
zYHRNmU;f*x8qjVt5g5)t{f?!%6
zqJWS_S@NU)HG}zDY_n(6n=6^pWp&@^09W&Pocg>-bVF+2D!k5Yu7iGmlBfve_3{Ud
zfDI7T5l-LU-L=~5HFA!r%aB(zLL$}Lp4!m}{UzFj8QJyG0+kLxS^vJ|0-nOi>Ga2!
zE!gr`dNZLmjBQ*{FW$6t5UQ*|406V5Nbhvd_0Whbw+#dImB~_0l8*`=Df?Z&LaXK9
z$L8Xa@0o=B9+ID>)U^IiYEKz&O{1Gjf|R#NjT_;O7oRP0x~nT)`E9B#KGXeB2cTuT
z0P<>cq1%T=x#ZcgQzZTS4+i$6_tdPbKf!vC^bJqIHDZy5X7^JW?p;-n|4aHo)}u;p
zgTmJ=mY2vOmw|$~gFI&Fr3yZb{K5eg*b@tPu6@UK&){9us=ge=LkZL57T-@1w$v>R
zTm7of|KH_+F-Iy3tsV)q9gbPl$(Z=-GmRp8Yc1&c35GL6HSR4AAWH}FnPH6kWNz$V
z1vx?=6MLQmz)H-P;>tl?0>(5x&GgA_| C5j;v+ShVZ2bDURGPqbHEgE1Xkj)EYVpHnb$}9}l1pv2wR}B!ERv@#fUX
zmbYJsJ{f4=A9sgsHu!Bfbgg6eb~dzD6@nOPgBS$aeaXu@`^z9q`|S@&K%y_DC3d|o
z7}*+LAN>RWPqH4NW;`c7!+>}%=r-NCv9DL10VTFarNfIrq|tTgE|Rq#GRau61+t!l
zE{E~v=OdwJzMGc&c_aJFEKDHq$KpV_{cpMJjPt=pPyi~Lp;z^`v#rnh4`cnLp{ubI
ze)#K$4cH~E4#J*JrDSGiI**PS$HUPBb5O;xR{pKR(TEtbw^4#M?&6oG7
z$;3Y6!AB`jE
zU1JsZ+w~!CP9-&TSOu;a#C5I+8l<52+)VolP3(hbH!G{*D}RcH$%g>;SS3-mn^lCH
z{*hF=+TujhWM7<=kK!{4NNPGcRpjea 6qF~J4&SFD{u7+75AkgKC
zu)C;jHOCqEj|GOM>;Q=W0X2Y_qE8TWWgjTD)|jL>1-Qb<*-BG+wdQA@A}PEW^Xn6p
z6?Grjv4v1u^ObGocK{f@5i8;8lx0r<(u@GDJjRD5cLKlpwsWP_Zu7ye+pzKUVUN)-
zuO>IZhHvG(dCLE(FS_t{@kJC^0jYOCKaXi>Rs@VXBa_&J%fg+keuS8Oe5LogQ|U;p
zr?cDIoT00OV}D7;m_!s*lq57a@!a3drN`|A_L;NlUE68c+h7?}luAPeNpHTDzCNo$
z*8_B>Q~=F9YLbZ=UGkQdDRND^m7C;r78$I8%quhN`|Zue_;-Pze08CM40
z-F@PP-re4ubXi88sSBCS_-g~Rr8@P|Qgyg75~J_NW|m|}&?mV)$(7XFs}*75rmppv
z7x2m%p2`dDNNRs8Qm%G|^GC@@wFQzk`8F55o-dtA;}n0{7Jbn{CE>f3m6eK%b?c_v
zY8|gZ@RNSh`28i#MF*(#NKLc&-rioD;rCu$`G%#bS5zb298l`zM^Pq=XLsU#F^#u0
zSV~qwO~e65-LK0&jCdAUNZs>hRy>S~o-dlw&{3Sugg=xQT62A68_H`K@4;-Ngiua`qgFyIUZDh{nAH9mm&v7fj?JD3pYjp~pWx
zc2-T)*yzdK9HT-zvG5d*vrP~nB}*-Wofc80JlHtHtVOUoGClab>ZTPGDPOVoXd@B?
ztY`(4BMM8AU_#HLeCbG=u6jj$AsJQhJac?hq#_rK9%E1JC6$(3y*#yKf+~G)XGhWw
z`Z&+rZQCFXy_RAglNzCNs*>d^2?mth$LmWzZ2~55o&RS3!JMT)My0`_5@wNy+kJZ^
zGsQh((k?!DzQ@3)*;P7cc?eASbSsuH4nar{&K#6|CcYL|W=F~1Zr>}7i+O>eKZ!Id|&
z>$3`tD%%e+qKce(!%~v4LZ4sIhQ9VVGjy$DzaK(AI
z4f_fXa^OaP`~)CIz)xKfb^1R27ypkECdr4L+s0jl
zn$R-kqPcR-5JL0H8PX=z&K5Ii^Q?Oko+mM*0LFF{jDTp)5Q5R>iIUcA6|xC^inLkk
zwoBZ>uL03EmdN>yzYm$OFTsf-fsj*q@$|vo88sNE-78&BeabhC_-wic(GeefnYvYV
zuX&<+g1F_H-7wr|;~n8hA$&2_skUbG9X@r|tFCYt7ZLk8@G?NZZNAKmG^bKQBu3XI
z2(c>L8cb-#?(S?gD1HUO^!pMi&&7V#F&`Crt}vx#9xBwXB3cG__u`=?t!_45O^QY%
zcMVm*hRAu&tZ+)9I_cerv||mnd_*kcs&YqONrOjN<%G4?GvD1?wSLF$ok@6bZX(Z8
z`;n#TOc=apW6k2VTJJ%pE8t>yXFGH#p9`331tkcW(AO-j+bLH%Mf
zqZ;ik!=KxF{NvmCr`wZo*3P$Ev{a+!ogyYi_`a?hCXBmh#_4$*zv^W!6P?y3&I*pN
zSo@iYWlR_??~v4UB_2fb8pmU2o_g$^(WSEBEWa_yAlVzpccbUfz~tfrM_T7sg6e&s
z=`E*sXeI>-#4Z9>bgA^GW*@DQ>J+N35!CvvrmB1A45z0wZtY@3KW*Z5R|eeDvdU)a
z3y8)ff!-U4#=fj&jRbzLx#EVJf}^LX_m-3!r!+>Y%N#-OWv8A%{vaA=tCr9VU6S3~
zede}z#*NCtIop#|c8XBtGL{N-tQ#{x{V}YOqmQTnWNB=2b(!D8NT}}Gfa_|AO5rmR
zu1-V64hGSweIMwKZN`i&t0iHg#G__5S6=vSykM8(<0Iu>@!5F~lcFyBwCyVSkv
znYu$?)FDt8k0oihL~xy$iBM=aTfJ4|$L8Pq7eQ9I%5+2~AyR)^H(h3uONd(B8(*;T6=9F)a-+*Wd;7H&{
z%@$O)u0XNw-wdL@=Hb$4wi(v93o!AFfEDR=vmOFu|FIjZ-=TV>qM~ABh&OzhFRtYUy#-(Z3c_1C^h8G4)`e!@Bw|e03iH>Ztupfhr5c{Q`7|=O9mg6AT
zM`2c~Qyx8^@k{*zq-C$qxpJX}!OD&@wdMd??o^=3DL^#`G0$wLS&~ey+#j=q*K{}{mNb_n5b7E*MH4aRwu?wEL+sxWnT{gdwYmgd%W#q
z)u5nrln^wWn;wo8f~uyY&@F4KUjtx+mX-(TLfEvy84W*@PHCk=@CAmp=<8`MB8~%P
zzsK+a7#rSlAt3j(U%+lJw^%vzBFi`c|LhorFfkD>Tn5GIk_0(GGagg1%D1>>%Rc~N
znfa^>hYD#3VOcCGudIxF(=fp!xfc0kO_I~7jr#vsy4B;x@Pfw3o6_vC06qG}7z0%pN%(8{9
zbeufa$=)}Qgo0Wt(|#sfD@`pqy8#qQf-UGqsE!7;ks{cML_)}yJmJM61L6knrpG(=
znMPV@3kZ>Mb-jx?!20q-L|3Wz1=`R
zmYs&YJp%xHlL7&|J-WSmq3>#+b%ByuM;LrL3hA|h;i#JHmove&UBIHaTCvt5^@h^^T&8?`R^xS7Y?XKi4d
z1c-0IgVtR1BQQW4w7B0*jnuOTdOm&MlHT*xGh^2(XxrH-ZR?;ngervTq%6DnT|&r7
z-H6QqyUob<@0L)#BkgCLM5uxXJ|bpZLVuOneG|OWCjl5}HM8ei5_|MIm+)svVmm%s
zSYU7BcTVw3Y?h=)?3wA*dRKLJ5T*i=FQ5DFR>8T)NOLCXMFP0K+D0?3vF!=|Y}f;!
z#dLuROx()YJtK*802R8{(uwDP+q0Cx^(}b}fd9%)X^l9qHKCPqz)cUZu6A~qO%ujQ
zZaUp7ilpzMnBkkdqt92O_@T%M8nF0Y<;_?Vkv(eQM=4{hjfcFZ04Zp
zY*Z6&anrd75zIm>`}>aTPjts_B1uvm8r#-OY4?6?Ed6ie0a@S2c@9j0%1aY@3We%_^y`=lIUH
z3wrHDpEVqWV<^Ta6iYlYRRnz^Ac+<|5BU{-Ok;29uk-eVwnXw?_Kb=?75t;)dZ_E_
ztB?MOCwi3n{vOgAKMP?GR)n+X!Jk{E=LD!!FM2BxjQP_
zo-_i~XknvV+h#rnCgW4|cg9o<^^SCR=@nG)qc*a)dR
zy({g&WL
zv8+=d@3Q}JaB>O;h86k*u#>pigRh0QVcid#Q#XgqGAq-2b4{_8$lnWiZ{2Kr3k2MM
z6B_`ksM^-+yIYz7KLlYpm#%4bwlB`9Kz`ZVF={l;p=WkopBpN}h#f266`ku9`^Am2
zpvdcB>9g**kDh!2iE%AQHdQ |