diff --git a/CLAUDE.md b/CLAUDE.md index 1d2a9e2..d06fd31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,4 +115,12 @@ steps: - Strict TypeScript with `noUncheckedIndexedAccess` - Zod schemas for runtime validation (`src/models/schemas.ts`) - Uses `@anthropic-ai/claude-agent-sdk` for Claude integration -- React/Ink for interactive terminal UI (`src/interactive/`) +- Simple CLI prompts in `src/prompt/` for user interaction + +## Command Design Principles + +**Keep commands minimal.** Avoid proliferating commands. One command per concept. + +- Use a single command with arguments/modes instead of multiple similar commands +- Example: `/config` opens permission mode selection. No need for `/sacrifice`, `/safe`, `/confirm`, etc. +- Before adding a new command, consider if existing commands can be extended diff --git a/package-lock.json b/package-lock.json index 5825eb2..f31642b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,27 @@ { - "name": "wolf-orchestrator", + "name": "takt", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "wolf-orchestrator", + "name": "takt", "version": "0.1.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.19", "chalk": "^5.3.0", "commander": "^12.1.0", - "ink": "^5.2.1", - "ink-spinner": "^5.0.0", - "react": "^18.3.1", "yaml": "^2.4.5", "zod": "^4.3.6" }, "bin": { - "wolf": "bin/wolf", - "wolf-cli": "dist/cli.js" + "takt": "bin/takt", + "takt-cli": "dist/cli.js" }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/node": "^20.14.0", - "@types/react": "^18.3.27", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", @@ -37,31 +33,6 @@ "node": ">=18.0.0" } }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", - "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=14.13.1" - } - }, - "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@anthropic-ai/claude-agent-sdk": { "version": "0.2.19", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.19.tgz", @@ -1382,24 +1353,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.53.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", @@ -1786,33 +1739,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1846,18 +1772,6 @@ "node": ">=12" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1934,101 +1848,6 @@ "node": ">= 16" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2065,15 +1884,6 @@ "dev": true, "license": "MIT" }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2089,13 +1899,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2131,24 +1934,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2156,16 +1941,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2574,18 +2349,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2659,94 +2422,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", - "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", - "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^7.0.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.22.0", - "indent-string": "^5.0.0", - "is-in-ci": "^1.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^7.2.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "node_modules/ink-spinner": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", - "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", - "license": "MIT", - "dependencies": { - "cli-spinners": "^2.7.0" - }, - "engines": { - "node": ">=14.16" - }, - "peerDependencies": { - "ink": ">=4.0.0", - "react": ">=18.0.0" - } - }, - "node_modules/ink/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2757,18 +2432,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2782,21 +2445,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2804,12 +2452,6 @@ "dev": true, "license": "ISC" }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2891,18 +2533,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2920,15 +2550,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2978,21 +2599,6 @@ "dev": true, "license": "MIT" }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3056,15 +2662,6 @@ "node": ">=6" } }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3171,34 +2768,6 @@ "node": ">=6" } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3209,22 +2778,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/rollup": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", @@ -3270,15 +2823,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3322,55 +2866,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3381,27 +2876,6 @@ "node": ">=0.10.0" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3416,38 +2890,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3561,18 +3003,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3810,21 +3240,6 @@ "node": ">=8" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3835,56 +3250,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -3913,12 +3278,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", - "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "license": "MIT" - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/src/__tests__/initialization.test.ts b/src/__tests__/initialization.test.ts index 8d775e5..b8acb2c 100644 --- a/src/__tests__/initialization.test.ts +++ b/src/__tests__/initialization.test.ts @@ -20,7 +20,7 @@ vi.mock('node:os', async () => { }); // Mock the prompt to avoid interactive input -vi.mock('../interactive/prompt.js', () => ({ +vi.mock('../prompt/index.js', () => ({ selectOptionWithDefault: vi.fn().mockResolvedValue('ja'), })); diff --git a/src/__tests__/input.test.ts b/src/__tests__/input.test.ts deleted file mode 100644 index 3f43814..0000000 --- a/src/__tests__/input.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -/** - * Tests for input handling module - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdirSync, rmSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { - InputHistoryManager, - EscapeSequenceTracker, - isMultilineInputTrigger, - hasBackslashContinuation, - removeBackslashContinuation, - type KeyEvent, -} from '../interactive/input.js'; -import { loadInputHistory, saveInputHistory } from '../config/paths.js'; - -describe('InputHistoryManager', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `takt-test-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - describe('constructor', () => { - it('should load existing history from file', () => { - saveInputHistory(testDir, ['entry1', 'entry2']); - - const manager = new InputHistoryManager(testDir); - - expect(manager.getHistory()).toEqual(['entry1', 'entry2']); - }); - - it('should start with empty history if no file exists', () => { - const manager = new InputHistoryManager(testDir); - - expect(manager.getHistory()).toEqual([]); - }); - - it('should initialize index at end of history', () => { - saveInputHistory(testDir, ['entry1', 'entry2']); - - const manager = new InputHistoryManager(testDir); - - expect(manager.getIndex()).toBe(2); - expect(manager.isAtHistoryEntry()).toBe(false); - }); - }); - - describe('add', () => { - it('should add entry and persist to file', () => { - const manager = new InputHistoryManager(testDir); - - manager.add('new entry'); - - expect(manager.getHistory()).toEqual(['new entry']); - expect(loadInputHistory(testDir)).toEqual(['new entry']); - }); - - it('should not add consecutive duplicates', () => { - const manager = new InputHistoryManager(testDir); - - manager.add('same'); - manager.add('same'); - - expect(manager.getHistory()).toEqual(['same']); - }); - - it('should allow non-consecutive duplicates', () => { - const manager = new InputHistoryManager(testDir); - - manager.add('first'); - manager.add('second'); - manager.add('first'); - - expect(manager.getHistory()).toEqual(['first', 'second', 'first']); - }); - }); - - describe('navigation', () => { - it('should navigate to previous entry', () => { - saveInputHistory(testDir, ['entry1', 'entry2', 'entry3']); - const manager = new InputHistoryManager(testDir); - - const entry1 = manager.navigatePrevious(); - const entry2 = manager.navigatePrevious(); - - expect(entry1).toBe('entry3'); - expect(entry2).toBe('entry2'); - expect(manager.getIndex()).toBe(1); - }); - - it('should return undefined when at start of history', () => { - saveInputHistory(testDir, ['entry1']); - const manager = new InputHistoryManager(testDir); - - manager.navigatePrevious(); // Move to entry1 - const result = manager.navigatePrevious(); // Try to go further back - - expect(result).toBeUndefined(); - expect(manager.getIndex()).toBe(0); - }); - - it('should navigate to next entry', () => { - saveInputHistory(testDir, ['entry1', 'entry2']); - const manager = new InputHistoryManager(testDir); - - manager.navigatePrevious(); // entry2 - manager.navigatePrevious(); // entry1 - const result = manager.navigateNext(); - - expect(result).toEqual({ entry: 'entry2', isCurrentInput: false }); - expect(manager.getIndex()).toBe(1); - }); - - it('should return current input when navigating past end', () => { - saveInputHistory(testDir, ['entry1']); - const manager = new InputHistoryManager(testDir); - - manager.saveCurrentInput('my current input'); - manager.navigatePrevious(); // entry1 - const result = manager.navigateNext(); // back to current - - expect(result).toEqual({ entry: 'my current input', isCurrentInput: true }); - expect(manager.isAtHistoryEntry()).toBe(false); - }); - - it('should return undefined when already at end', () => { - const manager = new InputHistoryManager(testDir); - - const result = manager.navigateNext(); - - expect(result).toBeUndefined(); - }); - }); - - describe('resetIndex', () => { - it('should reset index to end of history', () => { - saveInputHistory(testDir, ['entry1', 'entry2']); - const manager = new InputHistoryManager(testDir); - - manager.navigatePrevious(); - manager.navigatePrevious(); - manager.resetIndex(); - - expect(manager.getIndex()).toBe(2); - expect(manager.isAtHistoryEntry()).toBe(false); - }); - - it('should clear saved current input', () => { - const manager = new InputHistoryManager(testDir); - - manager.saveCurrentInput('some input'); - manager.resetIndex(); - - expect(manager.getCurrentInput()).toBe(''); - }); - }); - - describe('getCurrentEntry', () => { - it('should return entry at current index', () => { - saveInputHistory(testDir, ['entry1', 'entry2']); - const manager = new InputHistoryManager(testDir); - - manager.navigatePrevious(); // entry2 - - expect(manager.getCurrentEntry()).toBe('entry2'); - }); - - it('should return undefined when at end of history', () => { - saveInputHistory(testDir, ['entry1']); - const manager = new InputHistoryManager(testDir); - - expect(manager.getCurrentEntry()).toBeUndefined(); - }); - }); - - describe('length', () => { - it('should return history length', () => { - saveInputHistory(testDir, ['entry1', 'entry2', 'entry3']); - const manager = new InputHistoryManager(testDir); - - expect(manager.length).toBe(3); - }); - - it('should update after adding entries', () => { - const manager = new InputHistoryManager(testDir); - - expect(manager.length).toBe(0); - - manager.add('entry1'); - expect(manager.length).toBe(1); - - manager.add('entry2'); - expect(manager.length).toBe(2); - }); - }); -}); - -describe('EscapeSequenceTracker', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('constructor', () => { - it('should use default threshold of 50ms', () => { - const tracker = new EscapeSequenceTracker(); - expect(tracker.getThreshold()).toBe(50); - }); - - it('should accept custom threshold', () => { - const tracker = new EscapeSequenceTracker(100); - expect(tracker.getThreshold()).toBe(100); - }); - }); - - describe('isEscapeThenEnter', () => { - it('should return true when Enter is pressed within threshold after Escape', () => { - const tracker = new EscapeSequenceTracker(50); - - tracker.trackEscape(); - vi.advanceTimersByTime(30); // 30ms later - - expect(tracker.isEscapeThenEnter()).toBe(true); - }); - - it('should return false when Enter is pressed after threshold', () => { - const tracker = new EscapeSequenceTracker(50); - - tracker.trackEscape(); - vi.advanceTimersByTime(60); // 60ms later (exceeds 50ms threshold) - - expect(tracker.isEscapeThenEnter()).toBe(false); - }); - - it('should return false when Escape was never pressed', () => { - const tracker = new EscapeSequenceTracker(); - - expect(tracker.isEscapeThenEnter()).toBe(false); - }); - - it('should reset after returning true (prevent repeated triggers)', () => { - const tracker = new EscapeSequenceTracker(50); - - tracker.trackEscape(); - vi.advanceTimersByTime(30); - - expect(tracker.isEscapeThenEnter()).toBe(true); - // Second call should return false (already reset) - expect(tracker.isEscapeThenEnter()).toBe(false); - }); - - it('should not reset when returning false', () => { - const tracker = new EscapeSequenceTracker(50); - - tracker.trackEscape(); - vi.advanceTimersByTime(60); // Over threshold - - expect(tracker.isEscapeThenEnter()).toBe(false); - // Tracker should still have lastEscapeTime = 0 after false return - // New escape tracking should work - tracker.trackEscape(); - vi.advanceTimersByTime(30); - expect(tracker.isEscapeThenEnter()).toBe(true); - }); - }); - - describe('reset', () => { - it('should clear the tracked escape time', () => { - const tracker = new EscapeSequenceTracker(50); - - tracker.trackEscape(); - tracker.reset(); - vi.advanceTimersByTime(10); // Within threshold - - expect(tracker.isEscapeThenEnter()).toBe(false); - }); - }); -}); - -describe('isMultilineInputTrigger', () => { - let tracker: EscapeSequenceTracker; - - beforeEach(() => { - vi.useFakeTimers(); - tracker = new EscapeSequenceTracker(50); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const createKey = (overrides: Partial): KeyEvent => ({ - name: undefined, - ctrl: false, - meta: false, - shift: false, - sequence: undefined, - ...overrides, - }); - - describe('Ctrl+Enter', () => { - it('should return true for Ctrl+Enter', () => { - const key = createKey({ name: 'return', ctrl: true }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - - it('should return true for Ctrl+Enter with "enter" name', () => { - const key = createKey({ name: 'enter', ctrl: true }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - }); - - describe('Ctrl+J', () => { - it('should return true for Ctrl+J', () => { - const key = createKey({ name: 'j', ctrl: true }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - - it('should return true for Ctrl with linefeed sequence', () => { - const key = createKey({ ctrl: true, sequence: '\n' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - }); - - describe('Option+Enter (meta flag)', () => { - it('should return true for meta+Enter', () => { - const key = createKey({ name: 'return', meta: true }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - }); - - describe('Shift+Enter', () => { - it('should return true for Shift+Enter', () => { - const key = createKey({ name: 'return', shift: true }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - }); - - describe('Escape sequences', () => { - it('should return true for \\x1b\\r sequence (Terminal.app)', () => { - const key = createKey({ sequence: '\x1b\r' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - - it('should return true for \\u001b\\r sequence', () => { - const key = createKey({ sequence: '\u001b\r' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - - it('should return true for \\x1bOM sequence', () => { - const key = createKey({ sequence: '\x1bOM' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - }); - - describe('iTerm2-style Escape+Enter', () => { - it('should return true for Enter pressed within threshold after Escape', () => { - tracker.trackEscape(); - vi.advanceTimersByTime(30); - - const key = createKey({ name: 'return' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(true); - }); - - it('should return false for Enter pressed after threshold', () => { - tracker.trackEscape(); - vi.advanceTimersByTime(60); - - const key = createKey({ name: 'return' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(false); - }); - }); - - describe('Non-trigger keys', () => { - it('should return false for plain Enter', () => { - const key = createKey({ name: 'return' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(false); - }); - - it('should return false for other keys', () => { - const key = createKey({ name: 'a' }); - expect(isMultilineInputTrigger(key, tracker)).toBe(false); - }); - - it('should return false for Ctrl without Enter/J', () => { - const key = createKey({ name: 'k', ctrl: true }); - expect(isMultilineInputTrigger(key, tracker)).toBe(false); - }); - }); -}); - -describe('hasBackslashContinuation', () => { - it('should return true for line ending with single backslash', () => { - expect(hasBackslashContinuation('hello world\\')).toBe(true); - }); - - it('should return false for line ending with double backslash (escaped)', () => { - expect(hasBackslashContinuation('hello world\\\\')).toBe(false); - }); - - it('should return true for line ending with triple backslash', () => { - expect(hasBackslashContinuation('hello world\\\\\\')).toBe(true); - }); - - it('should return false for line without trailing backslash', () => { - expect(hasBackslashContinuation('hello world')).toBe(false); - }); - - it('should return false for empty line', () => { - expect(hasBackslashContinuation('')).toBe(false); - }); - - it('should return true for just a backslash', () => { - expect(hasBackslashContinuation('\\')).toBe(true); - }); - - it('should handle backslash in middle of line', () => { - expect(hasBackslashContinuation('path\\to\\file')).toBe(false); - expect(hasBackslashContinuation('path\\to\\file\\')).toBe(true); - }); -}); - -describe('removeBackslashContinuation', () => { - it('should remove trailing backslash', () => { - expect(removeBackslashContinuation('hello world\\')).toBe('hello world'); - }); - - it('should not modify line without trailing backslash', () => { - expect(removeBackslashContinuation('hello world')).toBe('hello world'); - }); - - it('should not remove escaped backslash (double)', () => { - expect(removeBackslashContinuation('hello world\\\\')).toBe('hello world\\\\'); - }); - - it('should remove only the continuation backslash from triple', () => { - expect(removeBackslashContinuation('hello world\\\\\\')).toBe('hello world\\\\'); - }); - - it('should handle empty string', () => { - expect(removeBackslashContinuation('')).toBe(''); - }); - - it('should handle just a backslash', () => { - expect(removeBackslashContinuation('\\')).toBe(''); - }); -}); diff --git a/src/__tests__/multiline-input.test.ts b/src/__tests__/multiline-input.test.ts deleted file mode 100644 index b99259a..0000000 --- a/src/__tests__/multiline-input.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Tests for multiline input state handling logic - * - * Tests the pure functions that handle state transformations for multiline text editing. - * Key detection logic has been moved to useRawKeypress.ts - see rawKeypress.test.ts for those tests. - */ - -import { describe, it, expect } from 'vitest'; -import { - handleCharacterInput, - handleNewLine, - handleBackspace, - handleLeftArrow, - handleRightArrow, - handleUpArrow, - handleDownArrow, - getFullInput, - createInitialState, - type MultilineInputState, -} from '../interactive/multilineInputLogic.js'; - -// Helper to create state -function createState(overrides: Partial = {}): MultilineInputState { - return { - lines: [''], - currentLine: 0, - cursor: 0, - ...overrides, - }; -} - -describe('Character input handling', () => { - it('should insert single character at cursor position', () => { - const state = createState({ lines: ['hello'], cursor: 5 }); - const result = handleCharacterInput(state, 'x'); - - expect(result.lines).toEqual(['hellox']); - expect(result.cursor).toBe(6); - }); - - it('should insert character in middle of text', () => { - const state = createState({ lines: ['helo'], cursor: 2 }); - const result = handleCharacterInput(state, 'l'); - - expect(result.lines).toEqual(['hello']); - expect(result.cursor).toBe(3); - }); - - it('should handle multi-byte characters (Japanese)', () => { - const state = createState({ lines: [''], cursor: 0 }); - const result = handleCharacterInput(state, 'こんにちは'); - - expect(result.lines).toEqual(['こんにちは']); - expect(result.cursor).toBe(5); // 5 characters - }); - - it('should insert multi-byte characters at correct position', () => { - const state = createState({ lines: ['Hello'], cursor: 5 }); - const result = handleCharacterInput(state, '日本語'); - - expect(result.lines).toEqual(['Hello日本語']); - expect(result.cursor).toBe(8); // 5 + 3 characters - }); - - it('should handle empty input', () => { - const state = createState({ lines: ['test'], cursor: 4 }); - const result = handleCharacterInput(state, ''); - - expect(result).toEqual(state); - }); -}); - -describe('New line handling', () => { - it('should split line at cursor position', () => { - const state = createState({ lines: ['hello world'], cursor: 5 }); - const result = handleNewLine(state); - - expect(result.lines).toEqual(['hello', ' world']); - expect(result.currentLine).toBe(1); - expect(result.cursor).toBe(0); - }); - - it('should add empty line at end', () => { - const state = createState({ lines: ['hello'], cursor: 5 }); - const result = handleNewLine(state); - - expect(result.lines).toEqual(['hello', '']); - expect(result.currentLine).toBe(1); - expect(result.cursor).toBe(0); - }); - - it('should add empty line at start', () => { - const state = createState({ lines: ['hello'], cursor: 0 }); - const result = handleNewLine(state); - - expect(result.lines).toEqual(['', 'hello']); - expect(result.currentLine).toBe(1); - expect(result.cursor).toBe(0); - }); -}); - -describe('Backspace handling', () => { - it('should delete character before cursor', () => { - const state = createState({ lines: ['hello'], cursor: 5 }); - const result = handleBackspace(state); - - expect(result.lines).toEqual(['hell']); - expect(result.cursor).toBe(4); - }); - - it('should delete character in middle of text', () => { - const state = createState({ lines: ['hello'], cursor: 3 }); - const result = handleBackspace(state); - - expect(result.lines).toEqual(['helo']); - expect(result.cursor).toBe(2); - }); - - it('should merge lines when at start of line', () => { - const state = createState({ - lines: ['line1', 'line2'], - currentLine: 1, - cursor: 0, - }); - const result = handleBackspace(state); - - expect(result.lines).toEqual(['line1line2']); - expect(result.currentLine).toBe(0); - expect(result.cursor).toBe(5); // After 'line1' - }); - - it('should do nothing at start of first line', () => { - const state = createState({ lines: ['hello'], cursor: 0 }); - const result = handleBackspace(state); - - expect(result).toEqual(state); - }); -}); - -describe('Arrow key navigation', () => { - describe('Left arrow', () => { - it('should move cursor left', () => { - const state = createState({ lines: ['hello'], cursor: 3 }); - const result = handleLeftArrow(state); - - expect(result.cursor).toBe(2); - }); - - it('should move to previous line at start', () => { - const state = createState({ - lines: ['line1', 'line2'], - currentLine: 1, - cursor: 0, - }); - const result = handleLeftArrow(state); - - expect(result.currentLine).toBe(0); - expect(result.cursor).toBe(5); - }); - - it('should do nothing at start of first line', () => { - const state = createState({ lines: ['hello'], cursor: 0 }); - const result = handleLeftArrow(state); - - expect(result).toEqual(state); - }); - }); - - describe('Right arrow', () => { - it('should move cursor right', () => { - const state = createState({ lines: ['hello'], cursor: 2 }); - const result = handleRightArrow(state); - - expect(result.cursor).toBe(3); - }); - - it('should move to next line at end', () => { - const state = createState({ - lines: ['line1', 'line2'], - currentLine: 0, - cursor: 5, - }); - const result = handleRightArrow(state); - - expect(result.currentLine).toBe(1); - expect(result.cursor).toBe(0); - }); - - it('should do nothing at end of last line', () => { - const state = createState({ lines: ['hello'], cursor: 5 }); - const result = handleRightArrow(state); - - expect(result).toEqual(state); - }); - }); - - describe('Up arrow', () => { - it('should move to previous line', () => { - const state = createState({ - lines: ['line1', 'line2'], - currentLine: 1, - cursor: 3, - }); - const result = handleUpArrow(state); - - expect(result.currentLine).toBe(0); - expect(result.cursor).toBe(3); - }); - - it('should adjust cursor if previous line is shorter', () => { - const state = createState({ - lines: ['ab', 'longer'], - currentLine: 1, - cursor: 5, - }); - const result = handleUpArrow(state); - - expect(result.currentLine).toBe(0); - expect(result.cursor).toBe(2); - }); - - it('should do nothing on first line', () => { - const state = createState({ lines: ['hello'], cursor: 3 }); - const result = handleUpArrow(state); - - expect(result).toEqual(state); - }); - }); - - describe('Down arrow', () => { - it('should move to next line', () => { - const state = createState({ - lines: ['line1', 'line2'], - currentLine: 0, - cursor: 3, - }); - const result = handleDownArrow(state); - - expect(result.currentLine).toBe(1); - expect(result.cursor).toBe(3); - }); - - it('should adjust cursor if next line is shorter', () => { - const state = createState({ - lines: ['longer', 'ab'], - currentLine: 0, - cursor: 5, - }); - const result = handleDownArrow(state); - - expect(result.currentLine).toBe(1); - expect(result.cursor).toBe(2); - }); - - it('should do nothing on last line', () => { - const state = createState({ lines: ['hello'], cursor: 3 }); - const result = handleDownArrow(state); - - expect(result).toEqual(state); - }); - }); -}); - -describe('Utility functions', () => { - describe('getFullInput', () => { - it('should join lines with newlines', () => { - const state = createState({ lines: ['line1', 'line2', 'line3'] }); - expect(getFullInput(state)).toBe('line1\nline2\nline3'); - }); - - it('should trim whitespace', () => { - const state = createState({ lines: [' hello ', ''] }); - expect(getFullInput(state)).toBe('hello'); - }); - - it('should return empty string for empty input', () => { - const state = createState({ lines: ['', ' ', ''] }); - expect(getFullInput(state)).toBe(''); - }); - }); - - describe('createInitialState', () => { - it('should create empty state', () => { - const state = createInitialState(); - - expect(state.lines).toEqual(['']); - expect(state.currentLine).toBe(0); - expect(state.cursor).toBe(0); - }); - }); -}); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 0c122ea..50faf39 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -100,10 +100,11 @@ export async function runCustomAgent( // Custom agent with prompt const systemPrompt = loadAgentPrompt(agentConfig); + const tools = agentConfig.allowedTools || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch']; const callOptions: ClaudeCallOptions = { cwd: options.cwd, sessionId: options.sessionId, - allowedTools: agentConfig.allowedTools || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'], + allowedTools: tools, model: options.model || agentConfig.model, statusPatterns: agentConfig.statusPatterns, onStream: options.onStream, diff --git a/src/cli.ts b/src/cli.ts index b916f30..4a6d92c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ * takt /switch - Switch workflow interactively * takt /clear - Clear agent conversation sessions * takt /help - Show help + * takt /config - Select permission mode interactively */ import { Command } from 'commander'; @@ -27,9 +28,10 @@ import { runAllTasks, showHelp, switchWorkflow, + switchConfig, } from './commands/index.js'; import { listWorkflows } from './config/workflowLoader.js'; -import { selectOptionWithDefault } from './interactive/prompt.js'; +import { selectOptionWithDefault } from './prompt/index.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js'; const log = createLogger('cli'); @@ -97,9 +99,13 @@ program showHelp(); return; + case 'config': + await switchConfig(cwd, args[0]); + return; + default: error(`Unknown command: /${command}`); - info('Available: /run-tasks, /switch, /clear, /help'); + info('Available: /run-tasks, /switch, /clear, /help, /config'); process.exit(1); } } diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..fde3fc5 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,141 @@ +/** + * Config switching command (like workflow switching) + * + * Permission mode selection that works from CLI. + * Uses selectOption for prompt selection, same pattern as switchWorkflow. + */ + +import chalk from 'chalk'; +import { info, success } from '../utils/ui.js'; +import { selectOption } from '../prompt/index.js'; +import { + loadProjectConfig, + updateProjectConfig, + type PermissionMode, +} from '../config/projectConfig.js'; + +// Re-export for convenience +export type { PermissionMode } from '../config/projectConfig.js'; + +/** + * Get permission mode options for selection + */ +/** Common permission mode option definitions */ +export const PERMISSION_MODE_OPTIONS: { + key: PermissionMode; + label: string; + description: string; + details: string[]; + icon: string; +}[] = [ + { + key: 'default', + label: 'デフォルト (default)', + description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)', + details: [ + 'Claude Agent SDKの標準設定(acceptEdits)を使用', + 'ファイル編集は自動承認され、確認プロンプトなしで実行', + 'Bash等の危険な操作は権限確認が表示される', + '通常の開発作業に推奨', + ], + icon: '📋', + }, + { + key: 'sacrifice-my-pc', + label: 'SACRIFICE-MY-PC', + description: '全ての権限リクエストが自動承認されます', + details: [ + '⚠️ 警告: 全ての操作が確認なしで実行されます', + 'Bash, ファイル削除, システム操作も自動承認', + 'ブロック状態(判断待ち)も自動スキップ', + '完全自動化が必要な場合のみ使用してください', + ], + icon: '💀', + }, +]; + +function getPermissionModeOptions(currentMode: PermissionMode): { + label: string; + value: PermissionMode; + description: string; + details: string[]; +}[] { + return PERMISSION_MODE_OPTIONS.map((opt) => ({ + label: currentMode === opt.key + ? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)' + : (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`), + value: opt.key, + description: opt.description, + details: opt.details, + })); +} + +/** + * Get current permission mode from project config + */ +export function getCurrentPermissionMode(cwd: string): PermissionMode { + const config = loadProjectConfig(cwd); + // Support both old sacrificeMode boolean and new permissionMode string + if (config.permissionMode) { + return config.permissionMode as PermissionMode; + } + // Legacy: convert sacrificeMode boolean to new format + if (config.sacrificeMode) { + return 'sacrifice-my-pc'; + } + return 'default'; +} + +/** + * Set permission mode in project config + */ +export function setPermissionMode(cwd: string, mode: PermissionMode): void { + updateProjectConfig(cwd, 'permissionMode', mode); + // @deprecated TODO: Remove in v1.0 - legacy sacrificeMode for backwards compatibility + updateProjectConfig(cwd, 'sacrificeMode', mode === 'sacrifice-my-pc'); +} + +/** + * Switch permission mode (like switchWorkflow) + * @returns true if switch was successful + */ +export async function switchConfig(cwd: string, modeName?: string): Promise { + const currentMode = getCurrentPermissionMode(cwd); + + // No mode specified - show selection prompt + if (!modeName) { + info(`Current mode: ${currentMode}`); + + const options = getPermissionModeOptions(currentMode); + const selected = await selectOption('Select permission mode:', options); + + if (!selected) { + info('Cancelled'); + return false; + } + + modeName = selected; + } + + // Validate mode name + if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') { + info(`Invalid mode: ${modeName}`); + info('Available modes: default, sacrifice-my-pc'); + return false; + } + + const finalMode: PermissionMode = modeName as PermissionMode; + + // Save to project config + setPermissionMode(cwd, finalMode); + + if (finalMode === 'sacrifice-my-pc') { + success('Switched to: sacrifice-my-pc 💀'); + info('All permission requests will be auto-approved.'); + } else { + success('Switched to: default 📋'); + info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).'); + } + + return true; +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 9e01fff..eab8eba 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -7,3 +7,4 @@ export { executeTask, runAllTasks, type ExecuteTaskOptions } from './taskExecuti export { showHelp } from './help.js'; export { withAgentSession } from './session.js'; export { switchWorkflow } from './workflow.js'; +export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js'; diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 8544ee6..6066a29 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -5,7 +5,7 @@ import { listWorkflows, loadWorkflow, getBuiltinWorkflow } from '../config/index.js'; import { getCurrentWorkflow, setCurrentWorkflow } from '../config/paths.js'; import { info, success, error } from '../utils/ui.js'; -import { selectOption } from '../interactive/prompt.js'; +import { selectOption } from '../prompt/index.js'; /** * Get all available workflow options diff --git a/src/config/initialization.ts b/src/config/initialization.ts index 2713d8d..97a1ab6 100644 --- a/src/config/initialization.ts +++ b/src/config/initialization.ts @@ -8,7 +8,7 @@ import { existsSync } from 'node:fs'; import type { Language } from '../models/types.js'; import { DEFAULT_LANGUAGE } from '../constants.js'; -import { selectOptionWithDefault } from '../interactive/prompt.js'; +import { selectOptionWithDefault } from '../prompt/index.js'; import { getGlobalConfigDir, getGlobalAgentsDir, diff --git a/src/config/projectConfig.ts b/src/config/projectConfig.ts index dbd7106..36bb2d0 100644 --- a/src/config/projectConfig.ts +++ b/src/config/projectConfig.ts @@ -8,11 +8,24 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parse, stringify } from 'yaml'; +/** Permission mode for the project + * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) + * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) + * + * Note: 'confirm' mode is planned but not yet implemented + */ +export type PermissionMode = 'default' | 'sacrifice-my-pc'; + +/** @deprecated Use PermissionMode instead */ +export type ProjectPermissionMode = PermissionMode; + /** Project configuration stored in .takt/config.yaml */ export interface ProjectLocalConfig { /** Current workflow name */ workflow?: string; - /** Auto-approve all permissions in this project */ + /** Permission mode setting */ + permissionMode?: PermissionMode; + /** @deprecated Use permissionMode instead. Auto-approve all permissions in this project */ sacrificeMode?: boolean; /** Verbose output mode */ verbose?: boolean; @@ -23,6 +36,7 @@ export interface ProjectLocalConfig { /** Default project configuration */ const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { workflow: 'default', + permissionMode: 'default', }; /** diff --git a/src/interactive/commands/agent.ts b/src/interactive/commands/agent.ts deleted file mode 100644 index efa5372..0000000 --- a/src/interactive/commands/agent.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Agent operation commands - * - * Commands: /agents, /agent - */ - -import chalk from 'chalk'; -import { listCustomAgents } from '../../config/index.js'; -import { runAgent } from '../../agents/runner.js'; -import { info, error, status, list, StreamDisplay } from '../../utils/ui.js'; -import { commandRegistry, createCommand } from './registry.js'; -import { multiLineQuestion } from '../input.js'; - -/** /agents - List available agents */ -commandRegistry.register( - createCommand(['agents'], 'List available agents', async (_) => { - const agents = listCustomAgents(); - info('Built-in: coder, architect, supervisor'); - if (agents.length > 0) { - info('Custom:'); - list(agents); - } - return { continue: true }; - }) -); - -/** /agent - Run a single agent with next input */ -commandRegistry.register( - createCommand( - ['agent'], - 'Run a single agent with next input', - async (args, state, rl) => { - if (args.length === 0) { - error('Usage: /agent '); - return { continue: true }; - } - - const agentName = args[0]; - if (!agentName) { - error('Usage: /agent '); - return { continue: true }; - } - - info(`Next input will be sent to agent: ${agentName}`); - - // Read next input for the agent using multiLineQuestion for multi-line support - const agentInput = await multiLineQuestion(rl, { - promptStr: chalk.cyan('Task> '), - onCtrlC: () => { - // Return true to cancel input and resolve with empty string - info('Cancelled'); - return true; - }, - historyManager: state.historyManager, - }); - - if (agentInput.trim()) { - const display = new StreamDisplay(agentName); - const streamHandler = display.createHandler(); - - try { - const response = await runAgent(agentName, agentInput, { - cwd: state.cwd, - onStream: streamHandler, - }); - display.flushThinking(); - display.flushText(); - console.log(); - status('Status', response.status); - } catch (err) { - display.flushThinking(); - display.flushText(); - error(err instanceof Error ? err.message : String(err)); - } - } - - return { continue: true }; - } - ) -); diff --git a/src/interactive/commands/basic.ts b/src/interactive/commands/basic.ts deleted file mode 100644 index af8e7e0..0000000 --- a/src/interactive/commands/basic.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Basic commands - * - * Commands: /help, /h, /quit, /exit, /q, /sacrifice - */ - -import chalk from 'chalk'; -import { info, success } from '../../utils/ui.js'; -import { commandRegistry, createCommand } from './registry.js'; -import { printHelp } from '../ui.js'; - -/** /help, /h - Show help message */ -commandRegistry.register( - createCommand(['help', 'h'], 'Show help message', async () => { - printHelp(); - return { continue: true }; - }) -); - -/** /quit, /exit, /q - Exit takt */ -commandRegistry.register( - createCommand(['quit', 'exit', 'q'], 'Exit takt', async () => { - info('Goodbye!'); - return { continue: false }; - }) -); - -/** /sacrifice, /yolo - Toggle sacrifice-my-pc mode */ -commandRegistry.register( - createCommand( - ['sacrifice', 'yolo', 'sacrificemypc', 'sacrifice-my-pc'], - 'Toggle sacrifice-my-pc mode (auto-approve everything)', - async (_args, state) => { - state.sacrificeMyPcMode = !state.sacrificeMyPcMode; - - if (state.sacrificeMyPcMode) { - console.log(); - console.log(chalk.red('━'.repeat(60))); - console.log(chalk.red.bold('⚠️ SACRIFICE-MY-PC MODE ENABLED ⚠️')); - console.log(chalk.red('━'.repeat(60))); - console.log(chalk.yellow('All permissions will be auto-approved.')); - console.log(chalk.yellow('Blocked states will be auto-skipped.')); - console.log(chalk.red('━'.repeat(60))); - console.log(); - success('Sacrifice mode: ON - May your PC rest in peace 💀'); - } else { - console.log(); - info('Sacrifice mode: OFF - Normal confirmation mode restored'); - } - - return { continue: true }; - } - ) -); - -/** /safe, /confirm - Disable sacrifice-my-pc mode and enable confirmation mode */ -commandRegistry.register( - createCommand( - ['safe', 'careful', 'confirm'], - 'Enable confirmation mode (prompt for permissions)', - async (_args, state) => { - if (state.sacrificeMyPcMode) { - state.sacrificeMyPcMode = false; - console.log(); - console.log(chalk.green('━'.repeat(60))); - console.log(chalk.green.bold('✓ 確認モードが有効になりました')); - console.log(chalk.green('━'.repeat(60))); - console.log(chalk.gray('権限リクエスト時に以下の選択肢が表示されます:')); - console.log(chalk.gray(' [y] 許可')); - console.log(chalk.gray(' [n] 拒否')); - console.log(chalk.gray(' [a] 今後も許可(セッション中)')); - console.log(chalk.gray(' [i] このイテレーションでこのコマンドを許可')); - console.log(chalk.gray(' [p] このイテレーションでこのコマンドパターンを許可')); - console.log(chalk.gray(' [s] このイテレーションでPC全権限譲渡')); - console.log(chalk.green('━'.repeat(60))); - console.log(); - success('確認モード: ON - 権限リクエストが表示されます'); - } else { - info('Already in confirmation mode'); - } - return { continue: true }; - } - ) -); - -/** /mode - Show current permission mode */ -commandRegistry.register( - createCommand(['mode', 'status'], 'Show current permission mode', async (_args, state) => { - console.log(); - if (state.sacrificeMyPcMode) { - console.log(chalk.red.bold('現在のモード: 💀 SACRIFICE-MY-PC MODE')); - console.log(chalk.yellow(' - 全ての権限リクエストが自動承認されます')); - console.log(chalk.yellow(' - ブロック状態は自動でスキップされます')); - console.log(chalk.gray('\n/confirm または /safe で確認モードに戻れます')); - } else { - console.log(chalk.green.bold('現在のモード: ✓ 確認モード')); - console.log(chalk.gray(' - 権限リクエスト時にプロンプトが表示されます')); - console.log(chalk.gray('\n/sacrifice で全自動モードに切り替えられます')); - } - console.log(); - return { continue: true }; - }) -); diff --git a/src/interactive/commands/index.ts b/src/interactive/commands/index.ts deleted file mode 100644 index 5bb5695..0000000 --- a/src/interactive/commands/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Command registry and all commands - * - * Import this module to register all commands with the registry. - */ - -// Export registry -export { commandRegistry, type Command, type CommandResult } from './registry.js'; - -// Import all command modules to trigger registration -import './basic.js'; -import './session.js'; -import './workflow.js'; -import './agent.js'; -import './task.js'; diff --git a/src/interactive/commands/registry.ts b/src/interactive/commands/registry.ts deleted file mode 100644 index 3f45d91..0000000 --- a/src/interactive/commands/registry.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Command registry for REPL - * - * Provides a Command pattern implementation for handling REPL commands. - * Commands are registered here and dispatched from the main REPL loop. - */ - -import type * as readline from 'node:readline'; -import type { InteractiveState } from '../types.js'; - -/** Command execution result */ -export interface CommandResult { - /** Whether to continue the REPL loop */ - continue: boolean; -} - -/** Command interface */ -export interface Command { - /** Command name(s) - first is primary, rest are aliases */ - names: string[]; - /** Brief description for help */ - description: string; - /** Execute the command */ - execute( - args: string[], - state: InteractiveState, - rl: readline.Interface - ): Promise; -} - -/** Command registry */ -class CommandRegistry { - private commands: Map = new Map(); - private allCommands: Command[] = []; - - /** Register a command */ - register(command: Command): void { - this.allCommands.push(command); - for (const name of command.names) { - this.commands.set(name.toLowerCase(), command); - } - } - - /** Get a command by name */ - get(name: string): Command | undefined { - return this.commands.get(name.toLowerCase()); - } - - /** Get all registered commands */ - getAll(): Command[] { - return this.allCommands; - } - - /** Check if a command exists */ - has(name: string): boolean { - return this.commands.has(name.toLowerCase()); - } -} - -/** Global command registry instance */ -export const commandRegistry = new CommandRegistry(); - -/** Helper to create a simple command */ -export function createCommand( - names: string[], - description: string, - execute: Command['execute'] -): Command { - return { names, description, execute }; -} diff --git a/src/interactive/commands/session.ts b/src/interactive/commands/session.ts deleted file mode 100644 index 09978d2..0000000 --- a/src/interactive/commands/session.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Session management commands - * - * Commands: /clear, /cls, /reset, /status, /history - */ - -import chalk from 'chalk'; -import { info, success, status, divider } from '../../utils/ui.js'; -import { generateSessionId } from '../../utils/session.js'; -import { setCurrentWorkflow } from '../../config/paths.js'; -import { commandRegistry, createCommand } from './registry.js'; -import { clearScreen, printWelcome } from '../ui.js'; - -/** /clear - Clear session and start fresh */ -commandRegistry.register( - createCommand(['clear'], 'Clear session and start fresh', async (_, state) => { - state.claudeSessionId = undefined; - state.conversationHistory = []; - state.sessionId = generateSessionId(); - clearScreen(); - printWelcome(state); - success('Session cleared. Starting fresh.'); - return { continue: true }; - }) -); - -/** /cls - Clear screen only (keep session) */ -commandRegistry.register( - createCommand(['cls'], 'Clear screen only (keep session)', async (_, state) => { - clearScreen(); - printWelcome(state); - return { continue: true }; - }) -); - -/** /reset - Full reset (session + workflow) */ -commandRegistry.register( - createCommand(['reset'], 'Full reset (session + workflow)', async (_, state) => { - state.claudeSessionId = undefined; - state.conversationHistory = []; - state.sessionId = generateSessionId(); - state.workflowName = 'default'; - setCurrentWorkflow(state.cwd, 'default'); - clearScreen(); - printWelcome(state); - success('Session and workflow reset.'); - return { continue: true }; - }) -); - -/** /status - Show current session info */ -commandRegistry.register( - createCommand(['status'], 'Show current session info', async (_, state) => { - divider(); - status('Session ID', state.sessionId); - status('Workflow', state.workflowName); - status('Project', state.cwd); - status('History', `${state.conversationHistory.length} messages`); - status( - 'Claude Session', - state.claudeSessionId || '(none - will create on first message)' - ); - divider(); - return { continue: true }; - }) -); - -/** /history - Show conversation history */ -commandRegistry.register( - createCommand(['history'], 'Show conversation history', async (_, state) => { - if (state.conversationHistory.length === 0) { - info('No conversation history'); - } else { - divider('═', 60); - console.log(chalk.bold.magenta(' Conversation History')); - divider('═', 60); - state.conversationHistory.forEach((msg, i) => { - const roleColor = msg.role === 'user' ? chalk.cyan : chalk.green; - const roleLabel = msg.role === 'user' ? 'You' : 'Assistant'; - const preview = - msg.content.length > 100 - ? msg.content.slice(0, 100) + '...' - : msg.content; - console.log(); - console.log(roleColor(`[${i + 1}] ${roleLabel}:`)); - console.log(chalk.gray(` ${preview}`)); - }); - console.log(); - divider('═', 60); - } - return { continue: true }; - }) -); diff --git a/src/interactive/commands/task.ts b/src/interactive/commands/task.ts deleted file mode 100644 index fc1773d..0000000 --- a/src/interactive/commands/task.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Task execution commands - * - * Commands: /task, /t - */ - -import chalk from 'chalk'; -import { header, info, error, success, divider, StreamDisplay } from '../../utils/ui.js'; -import { showTaskList, type TaskInfo, type TaskResult } from '../../task/index.js'; -import { commandRegistry, createCommand } from './registry.js'; -import { runAgent } from '../../agents/runner.js'; -import type { InteractiveState } from '../types.js'; - -/** Execute a task using coder agent */ -async function executeTaskWithAgent( - task: TaskInfo, - state: InteractiveState -): Promise { - const startedAt = new Date().toISOString(); - const executionLog: string[] = []; - - console.log(); - divider('=', 60); - header(`Task: ${task.name}`); - divider('=', 60); - console.log(chalk.cyan(`\n${task.content}\n`)); - divider('-', 60); - - let response: string; - let taskSuccess: boolean; - - try { - // Use stream display for real-time output - const display = new StreamDisplay('coder'); - const streamHandler = display.createHandler(); - - const result = await runAgent('coder', task.content, { - cwd: state.cwd, - onStream: (event) => { - if (event.type !== 'result') { - streamHandler(event); - } - }, - }); - - display.flush(); - - taskSuccess = result.status === 'done'; - response = result.content; - executionLog.push(`Response received: ${response.length} chars`); - } catch (err) { - response = `[ERROR] Task execution error: ${err instanceof Error ? err.message : String(err)}`; - taskSuccess = false; - executionLog.push(`Error: ${err}`); - } - - const completedAt = new Date().toISOString(); - - return { - task, - success: taskSuccess, - response, - executionLog, - startedAt, - completedAt, - }; -} - -/** Handle /task list subcommand */ -async function handleTaskList(state: InteractiveState): Promise { - showTaskList(state.taskRunner); -} - -/** Execute a single task and return the result */ -async function executeSingleTask( - task: TaskInfo, - state: InteractiveState -): Promise<{ result: TaskResult; reportFile: string }> { - // Execute the task - const result = await executeTaskWithAgent(task, state); - - // Mark task as completed - const reportFile = state.taskRunner.completeTask(result); - - console.log(); - divider('=', 60); - if (result.success) { - success('Task completed'); - } else { - error('Task failed'); - } - divider('=', 60); - info(`Report: ${reportFile}`); - - return { result, reportFile }; -} - -/** Run all tasks starting from the given task */ -async function runTasksFromStart( - startTask: TaskInfo, - state: InteractiveState -): Promise { - let task: TaskInfo | null = startTask; - let completedCount = 0; - let failedCount = 0; - - while (task) { - const { result } = await executeSingleTask(task, state); - - if (result.success) { - completedCount++; - } else { - failedCount++; - } - - task = state.taskRunner.getNextTask(); - - if (task) { - console.log(); - info(`Proceeding to next task: ${task.name}`); - divider('-', 60); - } - } - - console.log(); - divider('=', 60); - success(`All tasks completed! (${completedCount} succeeded, ${failedCount} failed)`); - divider('=', 60); -} - -/** Handle /task run [name] subcommand - runs all pending tasks (optionally starting from a specific task) */ -async function handleTaskRun( - taskName: string | undefined, - state: InteractiveState -): Promise { - let task: TaskInfo | null; - - if (taskName) { - task = state.taskRunner.getTask(taskName); - if (!task) { - error(`Task '${taskName}' not found`); - showTaskList(state.taskRunner); - return; - } - } else { - task = state.taskRunner.getNextTask(); - if (!task) { - info('No pending tasks.'); - console.log( - chalk.gray(`Place task files (.md) in ${state.taskRunner.getTasksDir()}/`) - ); - return; - } - } - - await runTasksFromStart(task, state); -} - -/** Handle /task all subcommand - alias for /task run (for backward compatibility) */ -async function handleTaskAll(state: InteractiveState): Promise { - const task = state.taskRunner.getNextTask(); - if (!task) { - info('No pending tasks.'); - console.log( - chalk.gray(`Place task files (.md) in ${state.taskRunner.getTasksDir()}/`) - ); - return; - } - - await runTasksFromStart(task, state); -} - -/** /task, /t - Task management command */ -commandRegistry.register( - createCommand( - ['task', 't'], - 'Task management (list/run)', - async (args, state) => { - const subcommand = args[0]?.toLowerCase() ?? ''; - const subargs = args.slice(1).join(' '); - - // /task or /task list - show task list - if (!subcommand || subcommand === 'list') { - await handleTaskList(state); - return { continue: true }; - } - - // /task run [name] - run all pending tasks (optionally starting from a specific task) - if (subcommand === 'run') { - await handleTaskRun(subargs || undefined, state); - return { continue: true }; - } - - // /task all - alias for /task run (backward compatibility) - if (subcommand === 'all') { - await handleTaskAll(state); - return { continue: true }; - } - - error(`Unknown subcommand: ${subcommand}`); - info('Usage: /task [list|run [name]|all]'); - return { continue: true }; - } - ) -); diff --git a/src/interactive/commands/workflow.ts b/src/interactive/commands/workflow.ts deleted file mode 100644 index abf69cb..0000000 --- a/src/interactive/commands/workflow.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Workflow management commands - * - * Commands: /switch, /sw, /workflow, /workflows - */ - -import { - loadWorkflow, - listWorkflows, - getBuiltinWorkflow, -} from '../../config/index.js'; -import { setCurrentWorkflow } from '../../config/paths.js'; -import { header, info, success, error, list } from '../../utils/ui.js'; -import { commandRegistry, createCommand } from './registry.js'; -import { selectWorkflow } from '../ui.js'; - -/** /switch, /sw - Interactive workflow selection */ -commandRegistry.register( - createCommand( - ['switch', 'sw'], - 'Switch workflow (interactive selection)', - async (_, state, rl) => { - const selected = await selectWorkflow(state, rl); - if (selected) { - state.workflowName = selected; - setCurrentWorkflow(state.cwd, selected); - success(`Switched to workflow: ${selected}`); - info('Enter a task to start the workflow.'); - } - return { continue: true }; - } - ) -); - -/** /workflow [name] - Show or change current workflow */ -commandRegistry.register( - createCommand( - ['workflow'], - 'Show or change current workflow', - async (args, state) => { - if (args.length > 0) { - const newWorkflow = args.join(' '); - // Check if it exists - const builtin = getBuiltinWorkflow(newWorkflow); - const custom = loadWorkflow(newWorkflow); - if (builtin || custom) { - state.workflowName = newWorkflow; - setCurrentWorkflow(state.cwd, newWorkflow); - success(`Switched to workflow: ${newWorkflow}`); - } else { - error(`Workflow not found: ${newWorkflow}`); - } - } else { - info(`Current workflow: ${state.workflowName}`); - } - return { continue: true }; - } - ) -); - -/** /workflows - List available workflows */ -commandRegistry.register( - createCommand(['workflows'], 'List available workflows', async () => { - const workflows = listWorkflows(); - if (workflows.length === 0) { - info('No workflows defined.'); - info('Add workflow files to ~/.takt/workflows/'); - } else { - header('Available Workflows'); - list(workflows); - } - return { continue: true }; - }) -); diff --git a/src/interactive/escape-tracker.ts b/src/interactive/escape-tracker.ts deleted file mode 100644 index 6cda3e5..0000000 --- a/src/interactive/escape-tracker.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Escape sequence tracking for iTerm2-style Option+Enter detection - * - * iTerm2 (and some other Mac terminals) send Option+Enter as two separate events: - * Escape followed by Enter. This module provides a tracker to detect this pattern - * by checking if Enter is pressed shortly after Escape. - */ - -/** - * Tracks Escape key timing for detecting iTerm2-style Option+Enter. - * - * The threshold of 50ms is based on: - * - Typical keyboard repeat delay is 200-500ms - * - Terminal escape sequence transmission is near-instantaneous (<10ms) - * - Human intentional Esc→Enter would take at least 100-150ms - * - 50ms provides a safe margin to detect machine-generated sequences - * while avoiding false positives from intentional key presses - */ -export class EscapeSequenceTracker { - private lastEscapeTime: number = 0; - private readonly thresholdMs: number; - - /** - * @param thresholdMs Time window to consider Esc+Enter as Option+Enter (default: 50ms) - */ - constructor(thresholdMs: number = 50) { - this.thresholdMs = thresholdMs; - } - - /** Record that Escape key was pressed */ - trackEscape(): void { - this.lastEscapeTime = Date.now(); - } - - /** - * Check if Enter was pressed within threshold of Escape. - * Resets the tracker if true to prevent repeated triggers. - */ - isEscapeThenEnter(): boolean { - const elapsed = Date.now() - this.lastEscapeTime; - const isRecent = elapsed < this.thresholdMs && this.lastEscapeTime > 0; - if (isRecent) { - this.lastEscapeTime = 0; // Reset to prevent accidental triggers - } - return isRecent; - } - - /** Reset the tracker state */ - reset(): void { - this.lastEscapeTime = 0; - } - - /** Get the threshold value (for testing) */ - getThreshold(): number { - return this.thresholdMs; - } -} diff --git a/src/interactive/handlers.ts b/src/interactive/handlers.ts deleted file mode 100644 index db14f16..0000000 --- a/src/interactive/handlers.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Interactive handlers for user questions and input - * - * Handles AskUserQuestion tool responses and user input prompts - * during workflow execution. - */ - -import chalk from 'chalk'; -import type { InputHistoryManager } from './input.js'; -import { multiLineQuestion, createReadlineInterface } from './input.js'; -import type { AskUserQuestionInput, AskUserQuestionHandler } from '../claude/process.js'; -import { runAgent } from '../agents/runner.js'; -import { info } from '../utils/ui.js'; - -/** - * Create a handler that uses another agent to answer questions. - * This allows automatic question answering by delegating to a specified agent. - */ -export function createAgentAnswerHandler( - answerAgentName: string, - cwd: string -): AskUserQuestionHandler { - return async (input: AskUserQuestionInput): Promise> => { - const answers: Record = {}; - - console.log(); - console.log(chalk.magenta('━'.repeat(60))); - console.log(chalk.magenta.bold(`🤖 ${answerAgentName} が質問に回答します`)); - console.log(chalk.magenta('━'.repeat(60))); - - for (let i = 0; i < input.questions.length; i++) { - const q = input.questions[i]; - if (!q) continue; - - const questionKey = `q${i}`; - - // Build a prompt for the answer agent - let prompt = `以下の質問に回答してください。回答のみを出力してください。\n\n`; - prompt += `質問: ${q.question}\n`; - - if (q.options && q.options.length > 0) { - prompt += `\n選択肢:\n`; - q.options.forEach((opt, idx) => { - prompt += `${idx + 1}. ${opt.label}`; - if (opt.description) { - prompt += ` - ${opt.description}`; - } - prompt += '\n'; - }); - prompt += `\n選択肢の番号またはラベルで回答してください。選択肢以外の回答も可能です。`; - } - - console.log(chalk.gray(`質問: ${q.question}`)); - - try { - const response = await runAgent(answerAgentName, prompt, { - cwd, - // Don't use session for answer agent - each question is independent - }); - - // Extract the answer from agent response - const answerContent = response.content.trim(); - - // If the agent selected a numbered option, convert to label - const options = q.options; - if (options && options.length > 0) { - const num = parseInt(answerContent, 10); - if (num >= 1 && num <= options.length) { - const selectedOption = options[num - 1]; - answers[questionKey] = selectedOption?.label ?? answerContent; - } else { - // Check if agent replied with exact label - const matchedOption = options.find( - opt => opt.label.toLowerCase() === answerContent.toLowerCase() - ); - if (matchedOption) { - answers[questionKey] = matchedOption.label; - } else { - answers[questionKey] = answerContent; - } - } - } else { - answers[questionKey] = answerContent; - } - - console.log(chalk.green(`回答: ${answers[questionKey]}`)); - } catch (err) { - console.log(chalk.red(`エージェントエラー: ${err instanceof Error ? err.message : String(err)}`)); - // Fall back to empty answer on error - answers[questionKey] = ''; - } - - console.log(); - } - - console.log(chalk.magenta('━'.repeat(60))); - console.log(); - - return answers; - }; -} - -/** - * Handle AskUserQuestion tool from Claude Code. - * Displays questions to the user and collects their answers. - */ -export function createAskUserQuestionHandler( - rl: ReturnType, - historyManager: InputHistoryManager -): AskUserQuestionHandler { - return async (input: AskUserQuestionInput): Promise> => { - const answers: Record = {}; - - console.log(); - console.log(chalk.blue('━'.repeat(60))); - console.log(chalk.blue.bold('❓ Claude Code からの質問')); - console.log(chalk.blue('━'.repeat(60))); - console.log(); - - for (let i = 0; i < input.questions.length; i++) { - const q = input.questions[i]; - if (!q) continue; - - const questionKey = `q${i}`; - - // Show the question - if (q.header) { - console.log(chalk.cyan.bold(`[${q.header}]`)); - } - console.log(chalk.white(q.question)); - - // Show options if available - const options = q.options; - if (options && options.length > 0) { - console.log(); - options.forEach((opt, idx) => { - const label = chalk.yellow(` ${idx + 1}. ${opt.label}`); - const desc = opt.description ? chalk.gray(` - ${opt.description}`) : ''; - console.log(label + desc); - }); - console.log(chalk.gray(` ${options.length + 1}. その他(自由入力)`)); - console.log(); - - // Prompt for selection - const answer = await new Promise((resolve) => { - multiLineQuestion(rl, { - promptStr: chalk.magenta('選択> '), - onCtrlC: () => { - resolve(''); - return true; - }, - historyManager, - }).then(resolve).catch(() => resolve('')); - }); - - const trimmed = answer.trim(); - const num = parseInt(trimmed, 10); - - if (num >= 1 && num <= options.length) { - // User selected an option - const selectedOption = options[num - 1]; - answers[questionKey] = selectedOption?.label ?? ''; - } else if (num === options.length + 1 || isNaN(num)) { - // User selected "Other" or entered free text - if (isNaN(num) && trimmed !== '') { - answers[questionKey] = trimmed; - } else { - console.log(chalk.cyan('自由入力してください:')); - const freeAnswer = await new Promise((resolve) => { - multiLineQuestion(rl, { - promptStr: chalk.magenta('回答> '), - onCtrlC: () => { - resolve(''); - return true; - }, - historyManager, - }).then(resolve).catch(() => resolve('')); - }); - answers[questionKey] = freeAnswer.trim(); - } - } else { - answers[questionKey] = trimmed; - } - } else { - // No options, free text input - console.log(); - const answer = await new Promise((resolve) => { - multiLineQuestion(rl, { - promptStr: chalk.magenta('回答> '), - onCtrlC: () => { - resolve(''); - return true; - }, - historyManager, - }).then(resolve).catch(() => resolve('')); - }); - answers[questionKey] = answer.trim(); - } - - console.log(); - } - - console.log(chalk.blue('━'.repeat(60))); - console.log(); - - return answers; - }; -} - -/** - * Create a handler for sacrifice mode that auto-skips all questions. - */ -export function createSacrificeModeQuestionHandler(): AskUserQuestionHandler { - return async (_input: AskUserQuestionInput): Promise> => { - info('[SACRIFICE MODE] Auto-skipping AskUserQuestion'); - return {}; - }; -} diff --git a/src/interactive/history-manager.ts b/src/interactive/history-manager.ts deleted file mode 100644 index 4ede1eb..0000000 --- a/src/interactive/history-manager.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Input history management with persistence - * - * Manages input history for the interactive REPL, providing: - * - In-memory history for session use - * - Persistent storage for cross-session recall - * - Navigation through history entries - */ - -import { - loadInputHistory, - saveInputHistory, -} from '../config/paths.js'; - -/** - * Manages input history with persistence. - * Provides a unified interface for in-memory and file-based history. - */ -export class InputHistoryManager { - private history: string[]; - private readonly projectDir: string; - private index: number; - private currentInput: string; - - constructor(projectDir: string) { - this.projectDir = projectDir; - this.history = loadInputHistory(projectDir); - this.index = this.history.length; - this.currentInput = ''; - } - - /** Add an entry to history (both in-memory and persistent) */ - add(input: string): void { - // Don't add consecutive duplicates - if (this.history[this.history.length - 1] !== input) { - this.history.push(input); - saveInputHistory(this.projectDir, this.history); - } - } - - /** Get the current history array (read-only) */ - getHistory(): readonly string[] { - return this.history; - } - - /** Get the current history index */ - getIndex(): number { - return this.index; - } - - /** Reset index to the end of history */ - resetIndex(): void { - this.index = this.history.length; - this.currentInput = ''; - } - - /** Save the current input before navigating history */ - saveCurrentInput(input: string): void { - if (this.index === this.history.length) { - this.currentInput = input; - } - } - - /** Get the saved current input */ - getCurrentInput(): string { - return this.currentInput; - } - - /** Navigate to the previous entry. Returns the entry or undefined if at start. */ - navigatePrevious(): string | undefined { - if (this.index > 0) { - this.index--; - return this.history[this.index]; - } - return undefined; - } - - /** Navigate to the next entry. Returns the entry, current input at end, or undefined. */ - navigateNext(): { entry: string; isCurrentInput: boolean } | undefined { - if (this.index < this.history.length) { - this.index++; - if (this.index === this.history.length) { - return { entry: this.currentInput, isCurrentInput: true }; - } - const entry = this.history[this.index]; - if (entry !== undefined) { - return { entry, isCurrentInput: false }; - } - } - return undefined; - } - - /** Check if currently at a history entry (not at the end) */ - isAtHistoryEntry(): boolean { - return this.index < this.history.length; - } - - /** Get the entry at the current index */ - getCurrentEntry(): string | undefined { - return this.history[this.index]; - } - - /** Get the total number of history entries */ - get length(): number { - return this.history.length; - } -} diff --git a/src/interactive/index.ts b/src/interactive/index.ts deleted file mode 100644 index 1c78c84..0000000 --- a/src/interactive/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Interactive module - exports REPL functionality - */ - -export * from './repl.js'; -export * from './input.js'; -export * from './types.js'; -export * from './ui.js'; diff --git a/src/interactive/input-handlers.ts b/src/interactive/input-handlers.ts deleted file mode 100644 index b7d07c3..0000000 --- a/src/interactive/input-handlers.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Input event handlers for multiline input - * - * Contains keypress and line handlers used by multiLineQuestion. - */ - -import * as readline from 'node:readline'; -import chalk from 'chalk'; -import { createLogger } from '../utils/debug.js'; -import { EscapeSequenceTracker } from './escape-tracker.js'; -import { InputHistoryManager } from './history-manager.js'; - -const log = createLogger('input'); - -/** Key event interface for keypress handling */ -export interface KeyEvent { - name?: string; - ctrl?: boolean; - meta?: boolean; - shift?: boolean; - sequence?: string; -} - -/** State for multiline input session */ -export interface MultilineInputState { - lines: string[]; - insertNewlineOnNextLine: boolean; - isFirstLine: boolean; - promptStr: string; -} - -/** Internal readline interface properties */ -interface ReadlineInternal { - line?: string; - cursor?: number; -} - -/** Get readline's internal line state */ -export function getReadlineLine(rl: readline.Interface): string { - const internal = rl as unknown as ReadlineInternal; - return internal.line ?? ''; -} - -/** Set readline's internal line and cursor state */ -export function setReadlineState(rl: readline.Interface, line: string, cursor: number): void { - const internal = rl as unknown as ReadlineInternal; - if ('line' in (rl as object)) { - internal.line = line; - } - if ('cursor' in (rl as object)) { - internal.cursor = cursor; - } -} - -/** Format a history entry for display (truncate multi-line entries) */ -export function formatHistoryEntry(entry: string): string { - const firstLine = entry.split('\n')[0] ?? ''; - const hasMoreLines = entry.includes('\n'); - return hasMoreLines ? firstLine + ' ...' : firstLine; -} - -/** - * Determines if a key event should trigger multi-line input mode. - */ -export function isMultilineInputTrigger( - key: KeyEvent, - escapeTracker: EscapeSequenceTracker -): boolean { - const isEnterKey = key.name === 'return' || key.name === 'enter'; - const modifiedEnter = isEnterKey && (key.ctrl || key.meta || key.shift); - const isCtrlJ = key.ctrl && (key.name === 'j' || key.sequence === '\n'); - const escapeSequences = - key.sequence === '\x1b\r' || - key.sequence === '\u001b\r' || - key.sequence === '\x1bOM' || - key.sequence === '\u001bOM'; - const iterm2Style = isEnterKey && escapeTracker.isEscapeThenEnter(); - - const result = modifiedEnter || isCtrlJ || escapeSequences || iterm2Style; - - if (isEnterKey || escapeSequences) { - log.debug('isMultilineInputTrigger', { - isEnterKey, modifiedEnter, isCtrlJ, escapeSequences, iterm2Style, result, - }); - } - - return result; -} - -/** Check if a line ends with backslash for line continuation */ -export function hasBackslashContinuation(line: string): boolean { - let backslashCount = 0; - for (let i = line.length - 1; i >= 0 && line[i] === '\\'; i--) { - backslashCount++; - } - return backslashCount % 2 === 1; -} - -/** Remove trailing backslash used for line continuation */ -export function removeBackslashContinuation(line: string): string { - if (hasBackslashContinuation(line)) { - return line.slice(0, -1); - } - return line; -} - -/** - * Create keypress handler for multiline input. - */ -export function createKeypressHandler( - rl: readline.Interface, - state: MultilineInputState, - escapeTracker: EscapeSequenceTracker, - historyManager: InputHistoryManager -): (str: string | undefined, key: KeyEvent) => void { - const replaceCurrentLine = (newContent: string): void => { - const currentLine = getReadlineLine(rl); - process.stdout.write('\r' + state.promptStr); - process.stdout.write(' '.repeat(currentLine.length)); - process.stdout.write('\r' + state.promptStr + newContent); - setReadlineState(rl, newContent, newContent.length); - }; - - return (_str: string | undefined, key: KeyEvent): void => { - if (!key) return; - - const seqHex = key.sequence - ? [...key.sequence].map(c => '0x' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ') - : '(none)'; - log.debug('keypress', { - name: key.name, sequence: seqHex, ctrl: key.ctrl, meta: key.meta, shift: key.shift, - }); - - if (key.name === 'escape' || key.sequence === '\x1b') { - escapeTracker.trackEscape(); - } - - if (isMultilineInputTrigger(key, escapeTracker)) { - state.insertNewlineOnNextLine = true; - return; - } - - if (!state.isFirstLine) return; - - if (key.name === 'up' && historyManager.length > 0) { - historyManager.saveCurrentInput(getReadlineLine(rl)); - const entry = historyManager.navigatePrevious(); - if (entry !== undefined) { - replaceCurrentLine(formatHistoryEntry(entry)); - } - return; - } - - if (key.name === 'down') { - const result = historyManager.navigateNext(); - if (result !== undefined) { - const displayText = result.isCurrentInput ? result.entry : formatHistoryEntry(result.entry); - replaceCurrentLine(displayText); - } - } - }; -} - -/** - * Create line handler for multiline input. - */ -export function createLineHandler( - rl: readline.Interface, - state: MultilineInputState, - historyManager: InputHistoryManager, - cleanup: () => void, - resolve: (value: string) => void -): (line: string) => void { - const showPrompt = (): void => { - const prefix = state.isFirstLine ? state.promptStr : chalk.gray('... '); - rl.setPrompt(prefix); - rl.prompt(); - }; - - return (line: string): void => { - const hasBackslash = hasBackslashContinuation(line); - log.debug('handleLine', { line, insertNewlineOnNextLine: state.insertNewlineOnNextLine, hasBackslash }); - - if (state.insertNewlineOnNextLine || hasBackslash) { - const cleanLine = hasBackslash ? removeBackslashContinuation(line) : line; - state.lines.push(cleanLine); - state.isFirstLine = false; - state.insertNewlineOnNextLine = false; - showPrompt(); - } else { - cleanup(); - - if (historyManager.isAtHistoryEntry() && state.isFirstLine) { - const historyEntry = historyManager.getCurrentEntry(); - if (historyEntry !== undefined) { - resolve(historyEntry); - return; - } - } - - state.lines.push(line); - resolve(state.lines.join('\n')); - } - }; -} - -/** - * Create SIGINT handler for multiline input. - */ -export function createSigintHandler( - rl: readline.Interface, - state: MultilineInputState, - historyManager: InputHistoryManager, - onCtrlC: () => boolean | void, - cleanup: () => void, - resolve: (value: string) => void -): () => void { - const showPrompt = (): void => { - const prefix = state.isFirstLine ? state.promptStr : chalk.gray('... '); - rl.setPrompt(prefix); - rl.prompt(); - }; - - return (): void => { - if (state.lines.length > 0) { - state.lines.length = 0; - state.isFirstLine = true; - state.insertNewlineOnNextLine = false; - historyManager.resetIndex(); - console.log(); - showPrompt(); - return; - } - - const shouldCancel = onCtrlC(); - if (shouldCancel === true) { - cleanup(); - resolve(''); - } - }; -} diff --git a/src/interactive/input.ts b/src/interactive/input.ts deleted file mode 100644 index 4c82a7d..0000000 --- a/src/interactive/input.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Input handling module for takt interactive mode - * - * Handles readline interface, multi-line input, and input history management. - * - * Multi-line input methods: - * - Ctrl+J: Works on all terminals (recommended for mac Terminal.app) - * - Ctrl+Enter: Works on terminals that support it - * - Option+Enter: Works on iTerm2 and some other Mac terminals - * - Backslash continuation: End line with \ to continue on next line - */ - -import * as readline from 'node:readline'; -import { emitKeypressEvents } from 'node:readline'; -import { EscapeSequenceTracker } from './escape-tracker.js'; -import { InputHistoryManager } from './history-manager.js'; -import { - createKeypressHandler, - createLineHandler, - createSigintHandler, - type MultilineInputState, -} from './input-handlers.js'; - -// Re-export for backward compatibility -export { EscapeSequenceTracker } from './escape-tracker.js'; -export { InputHistoryManager } from './history-manager.js'; -export { - isMultilineInputTrigger, - hasBackslashContinuation, - removeBackslashContinuation, - type KeyEvent, -} from './input-handlers.js'; - -/** Create readline interface with keypress support */ -export function createReadlineInterface(): readline.Interface { - if (process.stdin.isTTY) { - emitKeypressEvents(process.stdin); - } - - return readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); -} - -/** Options for multiLineQuestion */ -export interface MultiLineQuestionOptions { - promptStr: string; - /** - * Callback when Ctrl+C is pressed on the first line (no accumulated input). - * Return `true` to cancel and resolve with empty string. - * Return `void` or `false` to continue input (REPL behavior). - */ - onCtrlC: () => boolean | void; - historyManager: InputHistoryManager; -} - -/** - * Multi-line input using standard readline with Option+Return support and input history. - * - * This approach preserves all readline features (arrow keys, history, etc.) - * while adding multi-line support via keypress event interception. - * - * - Enter: submit input (execute) - * - Option+Enter (Mac) / Ctrl+Enter: insert newline (multi-line input) - * - Up Arrow: navigate to previous input in history - * - Down Arrow: navigate to next input in history - * - Ctrl+C: interrupt / cancel - */ -export function multiLineQuestion( - rl: readline.Interface, - options: MultiLineQuestionOptions -): Promise { - const { promptStr, onCtrlC, historyManager } = options; - - return new Promise((resolve) => { - const state: MultilineInputState = { - lines: [], - insertNewlineOnNextLine: false, - isFirstLine: true, - promptStr, - }; - - const escapeTracker = new EscapeSequenceTracker(); - historyManager.resetIndex(); - - const cleanup = (): void => { - process.stdin.removeListener('keypress', handleKeypress); - rl.removeListener('line', handleLine); - rl.removeListener('close', handleClose); - rl.removeListener('SIGINT', handleSigint); - }; - - const handleKeypress = createKeypressHandler(rl, state, escapeTracker, historyManager); - const handleLine = createLineHandler(rl, state, historyManager, cleanup, resolve); - const handleSigint = createSigintHandler(rl, state, historyManager, onCtrlC, cleanup, resolve); - - const handleClose = (): void => { - cleanup(); - resolve(state.lines.length > 0 ? state.lines.join('\n') : ''); - }; - - process.stdin.on('keypress', handleKeypress); - rl.on('line', handleLine); - rl.on('close', handleClose); - rl.on('SIGINT', handleSigint); - - rl.setPrompt(promptStr); - rl.prompt(); - }); -} diff --git a/src/interactive/multilineInputLogic.ts b/src/interactive/multilineInputLogic.ts deleted file mode 100644 index 65190d0..0000000 --- a/src/interactive/multilineInputLogic.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Multiline input state handling logic - * - * Pure functions for handling state transformations in multiline text editing. - */ - -/** State for multiline input */ -export interface MultilineInputState { - lines: string[]; - currentLine: number; - cursor: number; -} - -/** Create initial state */ -export function createInitialState(): MultilineInputState { - return { - lines: [''], - currentLine: 0, - cursor: 0, - }; -} - -/** Get full input as single string (trimmed) */ -export function getFullInput(state: MultilineInputState): string { - return state.lines.join('\n').trim(); -} - -/** Handle character input */ -export function handleCharacterInput( - state: MultilineInputState, - char: string -): MultilineInputState { - const { lines, currentLine, cursor } = state; - const line = lines[currentLine] || ''; - - const newLine = line.slice(0, cursor) + char + line.slice(cursor); - const newLines = [...lines]; - newLines[currentLine] = newLine; - - return { - lines: newLines, - currentLine, - cursor: cursor + char.length, - }; -} - -/** Handle newline insertion */ -export function handleNewLine(state: MultilineInputState): MultilineInputState { - const { lines, currentLine, cursor } = state; - const line = lines[currentLine] || ''; - - // Split current line at cursor - const before = line.slice(0, cursor); - const after = line.slice(cursor); - - const newLines = [ - ...lines.slice(0, currentLine), - before, - after, - ...lines.slice(currentLine + 1), - ]; - - return { - lines: newLines, - currentLine: currentLine + 1, - cursor: 0, - }; -} - -/** Handle backspace */ -export function handleBackspace(state: MultilineInputState): MultilineInputState { - const { lines, currentLine, cursor } = state; - const line = lines[currentLine] || ''; - - if (cursor > 0) { - // Delete character before cursor - const newLine = line.slice(0, cursor - 1) + line.slice(cursor); - const newLines = [...lines]; - newLines[currentLine] = newLine; - - return { - lines: newLines, - currentLine, - cursor: cursor - 1, - }; - } else if (currentLine > 0) { - // At start of line, merge with previous line - const prevLine = lines[currentLine - 1] || ''; - const mergedLine = prevLine + line; - - const newLines = [ - ...lines.slice(0, currentLine - 1), - mergedLine, - ...lines.slice(currentLine + 1), - ]; - - return { - lines: newLines, - currentLine: currentLine - 1, - cursor: prevLine.length, - }; - } - - // At start of first line, nothing to do - return state; -} - -/** Handle left arrow */ -export function handleLeftArrow(state: MultilineInputState): MultilineInputState { - const { lines, currentLine, cursor } = state; - - if (cursor > 0) { - return { ...state, cursor: cursor - 1 }; - } else if (currentLine > 0) { - // Move to end of previous line - const prevLineLength = (lines[currentLine - 1] || '').length; - return { - ...state, - currentLine: currentLine - 1, - cursor: prevLineLength, - }; - } - - return state; -} - -/** Handle right arrow */ -export function handleRightArrow(state: MultilineInputState): MultilineInputState { - const { lines, currentLine, cursor } = state; - const lineLength = (lines[currentLine] || '').length; - - if (cursor < lineLength) { - return { ...state, cursor: cursor + 1 }; - } else if (currentLine < lines.length - 1) { - // Move to start of next line - return { - ...state, - currentLine: currentLine + 1, - cursor: 0, - }; - } - - return state; -} - -/** Handle up arrow */ -export function handleUpArrow(state: MultilineInputState): MultilineInputState { - const { lines, currentLine, cursor } = state; - - if (currentLine > 0) { - const prevLineLength = (lines[currentLine - 1] || '').length; - return { - ...state, - currentLine: currentLine - 1, - cursor: Math.min(cursor, prevLineLength), - }; - } - - return state; -} - -/** Handle down arrow */ -export function handleDownArrow(state: MultilineInputState): MultilineInputState { - const { lines, currentLine, cursor } = state; - - if (currentLine < lines.length - 1) { - const nextLineLength = (lines[currentLine + 1] || '').length; - return { - ...state, - currentLine: currentLine + 1, - cursor: Math.min(cursor, nextLineLength), - }; - } - - return state; -} diff --git a/src/interactive/permission.ts b/src/interactive/permission.ts deleted file mode 100644 index 8b77e00..0000000 --- a/src/interactive/permission.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Interactive permission handler for takt - * - * Prompts user for permission when Claude requests access to tools - * that are not pre-approved. - */ - -import chalk from 'chalk'; -import * as readline from 'node:readline'; -import type { PermissionRequest, PermissionHandler } from '../claude/process.js'; -import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; -import { playInfoSound } from '../utils/notification.js'; - -/** Permission state for the current session */ -export interface PermissionState { - /** Temporarily allowed command patterns (for this iteration) */ - iterationAllowedPatterns: Set; - /** Sacrifice mode for current iteration */ - iterationSacrificeMode: boolean; -} - -/** Create initial permission state */ -export function createPermissionState(): PermissionState { - return { - iterationAllowedPatterns: new Set(), - iterationSacrificeMode: false, - }; -} - -/** Reset permission state for new iteration */ -export function resetPermissionStateForIteration(state: PermissionState): void { - state.iterationAllowedPatterns.clear(); - state.iterationSacrificeMode = false; -} - -/** Format tool input for display */ -function formatToolInput(toolName: string, input: Record): string { - if (toolName === 'Bash') { - const command = input.command as string | undefined; - const description = input.description as string | undefined; - if (command) { - const lines = [` コマンド: ${chalk.bold(command)}`]; - if (description) { - lines.push(` 説明: ${description}`); - } - return lines.join('\n'); - } - } - - if (toolName === 'Edit' || toolName === 'Write' || toolName === 'Read') { - const filePath = input.file_path as string | undefined; - if (filePath) { - return ` ファイル: ${chalk.bold(filePath)}`; - } - } - - if (toolName === 'WebSearch') { - const query = input.query as string | undefined; - if (query) { - return ` 検索: ${chalk.bold(query)}`; - } - } - - if (toolName === 'WebFetch') { - const url = input.url as string | undefined; - if (url) { - return ` URL: ${chalk.bold(url)}`; - } - } - - // Generic display for other tools - const entries = Object.entries(input).slice(0, 3); - return entries.map(([k, v]) => ` ${k}: ${JSON.stringify(v).slice(0, 50)}`).join('\n'); -} - -/** Build permission rule for the tool */ -function buildPermissionRule(toolName: string, input: Record): string { - if (toolName === 'Bash') { - const command = (input.command as string) || ''; - const firstWord = command.split(/\s+/)[0] || command; - return `Bash(${firstWord}:*)`; - } - return toolName; -} - -/** Build exact command pattern for iteration-scoped permission */ -function buildExactCommandPattern(toolName: string, input: Record): string { - if (toolName === 'Bash') { - const command = (input.command as string) || ''; - return `Bash:${command}`; - } - return `${toolName}:${JSON.stringify(input)}`; -} - -/** Check if a pattern matches the current request */ -function matchesPattern(pattern: string, toolName: string, input: Record): boolean { - // Check exact command pattern - const exactPattern = buildExactCommandPattern(toolName, input); - if (pattern === exactPattern) { - return true; - } - - // Check tool pattern (e.g., "Bash(gh:*)") - if (toolName === 'Bash' && pattern.startsWith('Bash(')) { - const command = (input.command as string) || ''; - const firstWord = command.split(/\s+/)[0] || ''; - const patternPrefix = pattern.match(/^Bash\(([^:]+):\*\)$/)?.[1]; - if (patternPrefix && firstWord === patternPrefix) { - return true; - } - } - - return false; -} - -/** - * Create an interactive permission handler with enhanced options - * - * @param rl - Readline interface for user input - * @param permissionState - Shared permission state for iteration-scoped permissions - * @returns Permission handler function - */ -export function createInteractivePermissionHandler( - rl: readline.Interface, - permissionState?: PermissionState -): PermissionHandler { - // Use provided state or create a new one - const state = permissionState || createPermissionState(); - - return async (request: PermissionRequest): Promise => { - const { toolName, input, suggestions, decisionReason } = request; - - // Check if sacrifice mode is active for this iteration - if (state.iterationSacrificeMode) { - return { behavior: 'allow' }; - } - - // Check if this command matches any iteration-allowed pattern - for (const pattern of state.iterationAllowedPatterns) { - if (matchesPattern(pattern, toolName, input)) { - return { behavior: 'allow' }; - } - } - - // Play notification sound - playInfoSound(); - - // Display permission request - console.log(); - console.log(chalk.yellow('━'.repeat(60))); - console.log(chalk.yellow.bold('⚠️ 権限リクエスト')); - console.log(` ツール: ${chalk.cyan(toolName)}`); - console.log(formatToolInput(toolName, input)); - if (decisionReason) { - console.log(chalk.gray(` 理由: ${decisionReason}`)); - } - console.log(chalk.yellow('━'.repeat(60))); - - // Show options - console.log(chalk.gray(' [y] 許可')); - console.log(chalk.gray(' [n] 拒否')); - console.log(chalk.gray(' [a] 今後も許可(セッション中)')); - console.log(chalk.gray(' [i] このイテレーションでこのコマンドを許可')); - console.log(chalk.gray(' [p] このイテレーションでこのコマンドパターンを許可')); - console.log(chalk.gray(' [s] このイテレーションでPC全権限譲渡(sacrificeモード)')); - - // Prompt user - const response = await new Promise((resolve) => { - rl.question( - chalk.yellow('選択してください [y/n/a/i/p/s]: '), - (answer) => { - resolve(answer.trim().toLowerCase()); - } - ); - }); - - if (response === 'y' || response === 'yes') { - // Allow this time only - console.log(chalk.green('✓ 許可しました')); - return { behavior: 'allow' }; - } - - if (response === 'a' || response === 'always') { - // Allow and remember for session - const rule = buildPermissionRule(toolName, input); - console.log(chalk.green(`✓ 許可しました (${rule} をセッション中記憶)`)); - - // Use suggestions if available, otherwise build our own - const updatedPermissions: PermissionUpdate[] = suggestions || [ - { - type: 'addRules', - rules: [{ toolName, ruleContent: rule }], - behavior: 'allow', - destination: 'session', - }, - ]; - - return { - behavior: 'allow', - updatedPermissions, - }; - } - - if (response === 'i') { - // Allow this exact command for this iteration - const exactPattern = buildExactCommandPattern(toolName, input); - state.iterationAllowedPatterns.add(exactPattern); - console.log(chalk.green('✓ このイテレーションでこのコマンドを許可しました')); - return { behavior: 'allow' }; - } - - if (response === 'p') { - // Allow this command pattern for this iteration - const pattern = buildPermissionRule(toolName, input); - state.iterationAllowedPatterns.add(pattern); - console.log(chalk.green(`✓ このイテレーションで ${pattern} パターンを許可しました`)); - return { behavior: 'allow' }; - } - - if (response === 's' || response === 'sacrifice') { - // Sacrifice mode for this iteration - state.iterationSacrificeMode = true; - console.log(chalk.red.bold('💀 このイテレーションでPC全権限を譲渡しました')); - return { behavior: 'allow' }; - } - - // Deny - console.log(chalk.red('✗ 拒否しました')); - return { - behavior: 'deny', - message: 'User denied permission', - }; - }; -} - -/** - * Create a non-interactive permission handler that auto-allows safe tools - * and denies others without prompting. - */ -export function createAutoPermissionHandler(): PermissionHandler { - // Tools that are always safe to allow - const safeTools = new Set([ - 'Read', - 'Glob', - 'Grep', - 'WebSearch', - 'WebFetch', - ]); - - // Safe Bash command prefixes - const safeBashPrefixes = [ - 'ls', 'cat', 'head', 'tail', 'find', 'grep', 'which', - 'pwd', 'echo', 'date', 'whoami', 'uname', - 'git status', 'git log', 'git diff', 'git branch', 'git show', - 'npm ', 'npx ', 'node ', 'python ', 'pip ', - ]; - - return async (request: PermissionRequest): Promise => { - const { toolName, input } = request; - - // Safe tools are always allowed - if (safeTools.has(toolName)) { - return { behavior: 'allow' }; - } - - // Check Bash commands - if (toolName === 'Bash') { - const command = ((input.command as string) || '').trim(); - for (const prefix of safeBashPrefixes) { - if (command.startsWith(prefix)) { - return { behavior: 'allow' }; - } - } - } - - // Deny other tools - return { - behavior: 'deny', - message: `Tool ${toolName} requires explicit permission`, - }; - }; -} diff --git a/src/interactive/repl.ts b/src/interactive/repl.ts deleted file mode 100644 index 871fb20..0000000 --- a/src/interactive/repl.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Interactive REPL mode for takt - * - * Provides an interactive shell similar to ORCA's interactive mode. - * Features: - * - Workflow switching with /switch (/sw) - * - Multi-agent workflow execution - * - Conversation history - * - Session persistence - */ - -import chalk from 'chalk'; -import { loadGlobalConfig } from '../config/index.js'; -import { - getCurrentWorkflow, - getProjectConfigDir, - ensureDir, -} from '../config/paths.js'; -import { interruptCurrentProcess } from '../claude/process.js'; -import { info } from '../utils/ui.js'; -import { generateSessionId } from '../utils/session.js'; -import { - createReadlineInterface, - multiLineQuestion, - InputHistoryManager, -} from './input.js'; -import { TaskRunner } from '../task/index.js'; -import { commandRegistry } from './commands/index.js'; -import { printWelcome } from './ui.js'; -import { executeMultiAgentWorkflow } from './workflow-executor.js'; -import type { InteractiveState } from './types.js'; - -/** - * Parse user input for iteration control. - * - * Returns the requested iteration count and the actual message. - * Examples: - * "3" -> { iterations: 3, message: null } (continue with 3 more iterations) - * "fix the bug" -> { iterations: 1, message: "fix the bug" } - * "5 do something" -> { iterations: 5, message: "do something" } - */ -function parseIterationInput(input: string): { iterations: number; message: string | null } { - const trimmed = input.trim(); - - // Check if input is just a number (continue iterations) - if (/^\d+$/.test(trimmed)) { - const count = parseInt(trimmed, 10); - if (count > 0 && count <= 100) { - return { iterations: count, message: null }; - } - } - - // Check if input starts with a number followed by space - const match = trimmed.match(/^(\d+)\s+(.+)$/); - if (match && match[1] && match[2]) { - const count = parseInt(match[1], 10); - if (count > 0 && count <= 100) { - return { iterations: count, message: match[2] }; - } - } - - // Default: single iteration with the full message - return { iterations: 1, message: trimmed }; -} - -/** Execute workflow with user message */ -async function executeWorkflow( - message: string, - state: InteractiveState, - rl: ReturnType -): Promise { - // Parse iteration control from input - const { iterations, message: actualMessage } = parseIterationInput(message); - - // Determine the task to use - let task: string; - if (actualMessage === null) { - // Number only - continue with previous task - if (!state.currentTask) { - info('継続するタスクがありません。タスクを入力してください。'); - return true; - } - task = state.currentTask; - info(`前回のタスクを ${iterations} イテレーションで継続します`); - } else { - task = actualMessage; - state.currentTask = task; - } - - // Add user message to conversation history - state.conversationHistory.push({ - role: 'user', - content: message, - timestamp: new Date().toISOString(), - }); - - // Add to input history (for up-arrow recall) - state.historyManager.add(message); - - // Add to shared user inputs (for all agents) - state.sharedUserInputs.push(task); - - // Run workflow with specified iterations - const response = await executeMultiAgentWorkflow(task, state, rl, iterations); - - // Add assistant response to history - state.conversationHistory.push({ - role: 'assistant', - content: response, - timestamp: new Date().toISOString(), - }); - - return true; -} - -/** Process user input */ -async function processInput( - input: string, - state: InteractiveState, - rl: ReturnType -): Promise { - const trimmed = input.trim(); - - if (!trimmed) { - return true; // Continue - } - - // Handle commands - if (trimmed.startsWith('/')) { - const parts = trimmed.slice(1).split(/\s+/); - const commandName = parts[0]?.toLowerCase(); - const args = parts.slice(1); - - if (!commandName) { - return true; - } - - const command = commandRegistry.get(commandName); - if (command) { - const result = await command.execute(args, state, rl); - return result.continue; - } - - info(`Unknown command: ${commandName}`); - info('Type /help for available commands'); - return true; - } - - // Execute workflow with input - return await executeWorkflow(trimmed, state, rl); -} - -/** Start interactive mode */ -export async function startInteractiveMode( - cwd: string, - initialTask?: string -): Promise { - // Load global config for validation - loadGlobalConfig(); - const lastWorkflow = getCurrentWorkflow(cwd); - - // Create history manager (handles persistence automatically) - const historyManager = new InputHistoryManager(cwd); - - // Create task runner - const taskRunner = new TaskRunner(cwd); - - const state: InteractiveState = { - cwd, - workflowName: lastWorkflow, - sessionId: generateSessionId(), - conversationHistory: [], - historyManager, - taskRunner, - sharedUserInputs: [], - sacrificeMyPcMode: false, - }; - - // Ensure project config directory exists - ensureDir(getProjectConfigDir(cwd)); - - printWelcome(state); - - const rl = createReadlineInterface(); - - // Handle initial task if provided - if (initialTask) { - const shouldContinue = await processInput(initialTask, state, rl); - if (!shouldContinue) { - rl.close(); - return; - } - } - - // Track Ctrl+C timing for double-press exit - let lastSigintTime = 0; - - // Ctrl+C handler for double-press exit - const handleCtrlC = (): void => { - console.log(); - const now = Date.now(); - - // Try to interrupt running Claude process first - if (interruptCurrentProcess()) { - info('Interrupted. Press Ctrl+C again to exit.'); - lastSigintTime = now; - } else if (now - lastSigintTime < 2000) { - // Double press within 2 seconds - exit - info('Goodbye!'); - rl.close(); - process.exit(0); - } else { - info('Press Ctrl+C again to exit'); - lastSigintTime = now; - } - }; - - // Main REPL loop with multi-line support - const prompt = async (): Promise => { - // Show workflow indicator above prompt - const modeIndicator = state.sacrificeMyPcMode ? chalk.red(' 💀') : ''; - console.log(chalk.gray(`[${state.workflowName}]`) + modeIndicator); - - try { - const promptStr = state.sacrificeMyPcMode - ? chalk.red('takt💀> ') - : chalk.cyan('takt> '); - - const input = await multiLineQuestion(rl, { - promptStr, - onCtrlC: handleCtrlC, - historyManager: state.historyManager, - }); - - if (input === '') { - // Empty input, just re-prompt - prompt(); - return; - } - - const shouldContinue = await processInput(input, state, rl); - if (shouldContinue) { - prompt(); - } else { - rl.close(); - } - } catch { - rl.close(); - } - }; - - prompt(); -} diff --git a/src/interactive/types.ts b/src/interactive/types.ts deleted file mode 100644 index 6c51417..0000000 --- a/src/interactive/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Interactive mode type definitions - */ - -import type { TaskRunner } from '../task/index.js'; -import type { InputHistoryManager } from './input.js'; - -/** Conversation message */ -export interface ConversationMessage { - role: 'user' | 'assistant'; - content: string; - timestamp: string; -} - -/** Interactive session state */ -export interface InteractiveState { - cwd: string; - workflowName: string; - sessionId: string; - claudeSessionId?: string; - conversationHistory: ConversationMessage[]; - historyManager: InputHistoryManager; - taskRunner: TaskRunner; - /** Current task for workflow continuation */ - currentTask?: string; - /** Requested number of iterations (for number input) */ - requestedIterations?: number; - /** All user inputs shared across agents */ - sharedUserInputs: string[]; - /** - * Sacrifice-my-pc mode: auto-approve all permissions and skip confirmations. - * When enabled, all permission requests are automatically approved, - * iteration limits auto-continue with 10 iterations, - * and blocked states are auto-skipped. - */ - sacrificeMyPcMode: boolean; -} diff --git a/src/interactive/ui.ts b/src/interactive/ui.ts deleted file mode 100644 index 87b3c68..0000000 --- a/src/interactive/ui.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Interactive mode UI functions - * - * Provides display and visual functions for the interactive REPL. - */ - -import * as readline from 'node:readline'; -import chalk from 'chalk'; -import { header, info, error, divider } from '../utils/ui.js'; -import { loadAllWorkflows } from '../config/index.js'; -import type { InteractiveState } from './types.js'; - -/** Clear screen */ -export function clearScreen(): void { - console.clear(); -} - -/** Print welcome banner */ -export function printWelcome(state: InteractiveState): void { - console.log(chalk.bold.cyan('═'.repeat(60))); - console.log(chalk.bold.cyan(' TAKT Interactive Mode')); - console.log(chalk.bold.cyan('═'.repeat(60))); - console.log(chalk.gray(`Project: ${state.cwd}`)); - console.log(chalk.gray(`Workflow: ${state.workflowName}`)); - if (state.sacrificeMyPcMode) { - console.log(chalk.red.bold('Mode: SACRIFICE-MY-PC 💀 (auto-approve all)')); - } - console.log(chalk.gray('Type /help for commands, /quit to exit')); - console.log(chalk.bold.cyan('═'.repeat(60))); - console.log(); -} - -/** Print help message */ -export function printHelp(): void { - header('TAKT Commands'); - console.log(` -${chalk.bold.yellow('Basic Operations:')} - [message] Send message to current workflow - Up/Down Arrow Navigate input history (persisted across sessions) - Enter Submit input (execute) - - ${chalk.bold.cyan('Multi-line input:')} - 末尾に \\ 行末にバックスラッシュで継続 (mac Terminal.app推奨) - Ctrl+J 改行を挿入 (全ターミナルで動作) - Ctrl+Enter 改行を挿入 (対応ターミナルのみ) - Option+Enter 改行を挿入 (iTerm2等) - - /help, /h Show this help - /quit, /exit, /q Exit takt - -${chalk.bold.yellow('Workflow Management:')} - /switch, /sw Switch workflow (interactive selection) - /workflow [name] Show or change current workflow - /workflows List available workflows - -${chalk.bold.yellow('Session Management:')} - /clear Clear session and start fresh - /cls Clear screen only (keep session) - /reset Full reset (session + workflow) - /status Show current session info - /history Show conversation history - -${chalk.bold.yellow('Agent Operations:')} - /agents List available agents - /agent Run a single agent with next input - -${chalk.bold.yellow('Task Execution:')} - /task, /t Show task list - /task run Execute next task - /task run Execute specified task - -${chalk.bold.yellow('Mode Control:')} - /sacrifice, /yolo Toggle sacrifice-my-pc mode (auto-approve all) - /safe Disable sacrifice mode - -${chalk.bold.yellow('Workflows:')} - default Coder -> Architect loop (default) - -${chalk.bold.cyan('Examples:')} - Implement a login feature - Review src/auth.ts and suggest improvements - Add tests for the previous code -`); -} - -/** Show workflow selector UI */ -export async function selectWorkflow( - state: InteractiveState, - rl: readline.Interface -): Promise { - const workflows = loadAllWorkflows(); - const workflowList = Array.from(workflows.entries()).sort((a, b) => - a[0].localeCompare(b[0]) - ); - - if (workflowList.length === 0) { - error('No workflows available'); - return null; - } - - console.log(); - divider('═', 60); - console.log(chalk.bold.magenta(' Workflow Selection')); - divider('═', 60); - console.log(); - - workflowList.forEach(([name, workflow], index) => { - const current = name === state.workflowName ? chalk.green(' (current)') : ''; - const desc = workflow.description || `${name} workflow`; - console.log(chalk.cyan(` [${index + 1}] ${name}${current}`)); - console.log(chalk.gray(` ${desc}`)); - }); - - console.log(chalk.yellow(` [0] Cancel`)); - console.log(); - divider('═', 60); - - return new Promise((resolve) => { - rl.question(chalk.cyan('Select workflow (number)> '), (input) => { - const trimmed = input.trim(); - - if (!trimmed || trimmed === '0') { - info('Cancelled'); - resolve(null); - return; - } - - const index = parseInt(trimmed, 10) - 1; - const entry = workflowList[index]; - if (index >= 0 && entry) { - const [name] = entry; - resolve(name); - } else { - error('Invalid selection'); - resolve(null); - } - }); - }); -} diff --git a/src/interactive/user-input.ts b/src/interactive/user-input.ts deleted file mode 100644 index 048e758..0000000 --- a/src/interactive/user-input.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * User input request handlers for workflow execution - * - * Handles user input prompts when an agent is blocked - * or iteration limits are reached. - */ - -import chalk from 'chalk'; -import type { InputHistoryManager } from './input.js'; -import { multiLineQuestion, createReadlineInterface } from './input.js'; -import type { UserInputRequest, IterationLimitRequest } from '../workflow/engine.js'; -import { info } from '../utils/ui.js'; -import { playInfoSound } from '../utils/notification.js'; - -/** - * Request user input for blocked workflow step. - * - * Displays the blocked message and prompts the user for additional information. - * Returns null if the user cancels or provides empty input. - */ -export async function requestUserInput( - request: UserInputRequest, - rl: ReturnType, - historyManager: InputHistoryManager -): Promise { - // Play notification sound to alert user - playInfoSound(); - - console.log(); - console.log(chalk.yellow('━'.repeat(60))); - console.log(chalk.yellow.bold('❓ エージェントからの質問')); - console.log(chalk.gray(`ステップ: ${request.step.name} (${request.step.agentDisplayName})`)); - console.log(); - console.log(chalk.white(request.response.content)); - console.log(chalk.yellow('━'.repeat(60))); - console.log(); - console.log(chalk.cyan('回答を入力してください(キャンセル: Ctrl+C)')); - console.log(); - - return new Promise((resolve) => { - multiLineQuestion(rl, { - promptStr: chalk.magenta('回答> '), - onCtrlC: () => { - console.log(); - info('ユーザー入力がキャンセルされました'); - resolve(null); - return true; // Cancel input - }, - historyManager, - }).then((input) => { - if (input.trim() === '') { - info('空の入力のためキャンセルされました'); - resolve(null); - } else { - resolve(input); - } - }).catch(() => { - resolve(null); - }); - }); -} - -/** - * Handle iteration limit reached. - * Ask user if they want to continue and how many additional iterations. - * - * Returns: - * - number: The number of additional iterations to continue - * - null: User chose to stop the workflow - */ -export async function requestIterationContinue( - request: IterationLimitRequest, - rl: ReturnType, - historyManager: InputHistoryManager -): Promise { - // Play notification sound to alert user - playInfoSound(); - - console.log(); - console.log(chalk.yellow('━'.repeat(60))); - console.log(chalk.yellow.bold('⏸ イテレーション上限に達しました')); - console.log(chalk.gray(`現在: ${request.currentIteration}/${request.maxIterations} イテレーション`)); - console.log(chalk.gray(`ステップ: ${request.currentStep}`)); - console.log(chalk.yellow('━'.repeat(60))); - console.log(); - console.log(chalk.cyan('続けますか?')); - console.log(chalk.gray(' - 数字を入力: 追加イテレーション数(例: 5)')); - console.log(chalk.gray(' - Enter: デフォルト10イテレーション追加')); - console.log(chalk.gray(' - Ctrl+C または "n": 終了')); - console.log(); - - return new Promise((resolve) => { - multiLineQuestion(rl, { - promptStr: chalk.magenta('追加イテレーション> '), - onCtrlC: () => { - console.log(); - info('ワークフローを終了します'); - resolve(null); - return true; - }, - historyManager, - }).then((input) => { - const trimmed = input.trim().toLowerCase(); - - // User wants to stop - if (trimmed === 'n' || trimmed === 'no' || trimmed === 'q' || trimmed === 'quit') { - info('ワークフローを終了します'); - resolve(null); - return; - } - - // Empty input = default 10 iterations - if (trimmed === '' || trimmed === 'y' || trimmed === 'yes') { - info('10 イテレーション追加します'); - resolve(10); - return; - } - - // Try to parse as number - const num = parseInt(trimmed, 10); - if (!isNaN(num) && num > 0 && num <= 100) { - info(`${num} イテレーション追加します`); - resolve(num); - return; - } - - // Invalid input, treat as continue with default - info('10 イテレーション追加します'); - resolve(10); - }).catch(() => { - resolve(null); - }); - }); -} diff --git a/src/interactive/workflow-executor.ts b/src/interactive/workflow-executor.ts deleted file mode 100644 index 4bb5dff..0000000 --- a/src/interactive/workflow-executor.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Workflow executor for interactive mode - * - * Handles the execution of multi-agent workflows, - * including streaming output and state management. - */ - -import chalk from 'chalk'; -import { - loadWorkflow, - getBuiltinWorkflow, -} from '../config/index.js'; -import { - loadAgentSessions, - updateAgentSession, -} from '../config/paths.js'; -import { WorkflowEngine, type UserInputRequest, type IterationLimitRequest } from '../workflow/engine.js'; -import { - info, - error, - success, - StreamDisplay, -} from '../utils/ui.js'; -import { - playWarningSound, - notifySuccess, - notifyError, - notifyWarning, -} from '../utils/notification.js'; -import { - createSessionLog, - addToSessionLog, - finalizeSessionLog, - saveSessionLog, -} from '../utils/session.js'; -import { createReadlineInterface } from './input.js'; -import { - createInteractivePermissionHandler, - createPermissionState, - resetPermissionStateForIteration, -} from './permission.js'; -import { - createAgentAnswerHandler, - createAskUserQuestionHandler, - createSacrificeModeQuestionHandler, -} from './handlers.js'; -import { requestUserInput, requestIterationContinue } from './user-input.js'; -import type { InteractiveState } from './types.js'; -import type { WorkflowConfig } from '../models/types.js'; -import type { AskUserQuestionHandler } from '../claude/process.js'; - -/** - * Execute multi-agent workflow with streaming output. - * - * This is the main workflow execution function that: - * - Loads and validates the workflow configuration - * - Sets up stream handlers for real-time output - * - Manages agent sessions for conversation continuity - * - Handles blocked states and user input requests - * - Logs session data for debugging - */ -export async function executeMultiAgentWorkflow( - message: string, - state: InteractiveState, - rl: ReturnType, - requestedIterations: number = 10 -): Promise { - const builtin = getBuiltinWorkflow(state.workflowName); - let config: WorkflowConfig | null = - builtin || loadWorkflow(state.workflowName); - - if (!config) { - error(`Workflow "${state.workflowName}" not found.`); - info('Available workflows: /workflow list'); - return `[ERROR] Workflow "${state.workflowName}" not found`; - } - - // Apply requested iteration count - if (requestedIterations !== config.maxIterations) { - config = { ...config, maxIterations: requestedIterations }; - } - - const sessionLog = createSessionLog(message, state.cwd, config.name); - - // Track current display for streaming - const displayRef: { current: StreamDisplay | null } = { current: null }; - - // Create stream handler that delegates to current display - const streamHandler = ( - event: Parameters>[0] - ): void => { - if (!displayRef.current) return; - if (event.type === 'result') return; - displayRef.current.createHandler()(event); - }; - - // Create user input handler for blocked state - const userInputHandler = async (request: UserInputRequest): Promise => { - // In sacrifice mode, auto-skip blocked states - if (state.sacrificeMyPcMode) { - info('[SACRIFICE MODE] Auto-skipping blocked state'); - return null; - } - - // Flush current display before prompting - if (displayRef.current) { - displayRef.current.flushThinking(); - displayRef.current.flushText(); - displayRef.current = null; - } - return requestUserInput(request, rl, state.historyManager); - }; - - // Create iteration limit handler - // Note: Even in sacrifice mode, we ask user for iteration continuation - // to prevent runaway execution - const iterationLimitHandler = async (request: IterationLimitRequest): Promise => { - // Flush current display before prompting - if (displayRef.current) { - displayRef.current.flushThinking(); - displayRef.current.flushText(); - displayRef.current = null; - } - return requestIterationContinue(request, rl, state.historyManager); - }; - - // Load saved agent sessions for session resumption - const savedSessions = loadAgentSessions(state.cwd); - - // Session update handler - persist session IDs when they change - const sessionUpdateHandler = (agentName: string, sessionId: string): void => { - updateAgentSession(state.cwd, agentName, sessionId); - }; - - // Create permission state for iteration-scoped permissions - const permissionState = createPermissionState(); - - // Create interactive permission handler (sacrifice mode uses bypassPermissions) - const permissionHandler = state.sacrificeMyPcMode - ? undefined // No handler needed - we'll use bypassPermissions mode - : createInteractivePermissionHandler(rl, permissionState); - - // Create AskUserQuestion handler - // Priority: sacrifice mode > answerAgent > interactive user input - let askUserQuestionHandler: AskUserQuestionHandler; - if (state.sacrificeMyPcMode) { - askUserQuestionHandler = createSacrificeModeQuestionHandler(); - } else if (config.answerAgent) { - // Use another agent to answer questions - info(`質問回答エージェント: ${config.answerAgent}`); - askUserQuestionHandler = createAgentAnswerHandler(config.answerAgent, state.cwd); - } else { - // Interactive user input - askUserQuestionHandler = createAskUserQuestionHandler(rl, state.historyManager); - } - - const engine = new WorkflowEngine(config, state.cwd, message, { - onStream: streamHandler, - onUserInput: userInputHandler, - initialSessions: savedSessions, - onSessionUpdate: sessionUpdateHandler, - onPermissionRequest: permissionHandler, - initialUserInputs: state.sharedUserInputs, - onAskUserQuestion: askUserQuestionHandler, - onIterationLimit: iterationLimitHandler, - bypassPermissions: state.sacrificeMyPcMode, - }); - - engine.on('step:start', (step, iteration) => { - // Reset iteration-scoped permission state at start of each step - resetPermissionStateForIteration(permissionState); - info(`[${iteration}/${config.maxIterations}] ${step.name} (${step.agentDisplayName})`); - displayRef.current = new StreamDisplay(step.agentDisplayName); - }); - - engine.on('step:complete', (step, stepResponse) => { - if (displayRef.current) { - displayRef.current.flushThinking(); - displayRef.current.flushText(); - displayRef.current = null; - } - console.log(); - addToSessionLog(sessionLog, step.name, stepResponse); - }); - - // Handle user input event (after user provides input for blocked step) - engine.on('step:user_input', (step, userInput) => { - console.log(); - info(`ユーザー入力を受け取りました。${step.name} を再実行します...`); - console.log(chalk.gray(`入力内容: ${userInput.slice(0, 100)}${userInput.length > 100 ? '...' : ''}`)); - console.log(); - }); - - let wasInterrupted = false; - let loopDetected = false; - let wasBlocked = false; - engine.on('workflow:abort', (_, reason) => { - if (displayRef.current) { - displayRef.current.flushThinking(); - displayRef.current.flushText(); - displayRef.current = null; - } - if (reason?.includes('interrupted')) { - wasInterrupted = true; - } - if (reason?.includes('Loop detected')) { - loopDetected = true; - } - if (reason?.includes('blocked') || reason?.includes('no user input')) { - wasBlocked = true; - } - }); - - try { - const finalState = await engine.run(); - - const statusVal = finalState.status === 'completed' ? 'completed' : 'aborted'; - finalizeSessionLog(sessionLog, statusVal); - saveSessionLog(sessionLog, state.sessionId, state.cwd); - - if (finalState.status === 'completed') { - success('Workflow completed!'); - notifySuccess('TAKT', `ワークフロー完了 (${finalState.iteration} iterations)`); - return '[WORKFLOW COMPLETE]'; - } else if (wasInterrupted) { - info('Workflow interrupted by user'); - // User intentionally interrupted - sound only, no system notification needed - playWarningSound(); - return '[WORKFLOW INTERRUPTED]'; - } else if (loopDetected) { - error('Workflow aborted due to loop detection'); - info('Tip: ループが検出されました。タスクを見直すか、/agent coder を直接使用してください。'); - notifyError('TAKT', 'ループ検出により中断されました'); - return '[WORKFLOW ABORTED: Loop detected]'; - } else if (wasBlocked) { - info('Workflow aborted: エージェントがブロックされ、ユーザー入力が提供されませんでした'); - notifyWarning('TAKT', 'ユーザー入力待ちで中断されました'); - return '[WORKFLOW ABORTED: Blocked]'; - } else { - error('Workflow aborted'); - notifyError('TAKT', 'ワークフローが中断されました'); - return '[WORKFLOW ABORTED]'; - } - } catch (err) { - if (displayRef.current) { - displayRef.current.flushThinking(); - displayRef.current.flushText(); - } - const errMsg = `[ERROR] ${err instanceof Error ? err.message : String(err)}`; - error(errMsg); - notifyError('TAKT', `エラー: ${err instanceof Error ? err.message : String(err)}`); - return errMsg; - } -} diff --git a/src/interactive/prompt.ts b/src/prompt/index.ts similarity index 90% rename from src/interactive/prompt.ts rename to src/prompt/index.ts index 63b7d90..79a4155 100644 --- a/src/interactive/prompt.ts +++ b/src/prompt/index.ts @@ -13,7 +13,7 @@ import chalk from 'chalk'; */ export async function selectOption( message: string, - options: { label: string; value: T }[] + options: { label: string; value: T; description?: string; details?: string[] }[] ): Promise { const rl = readline.createInterface({ input: process.stdin, @@ -26,6 +26,16 @@ export async function selectOption( options.forEach((opt, idx) => { console.log(chalk.yellow(` ${idx + 1}. `) + opt.label); + // Display description if provided + if (opt.description) { + console.log(chalk.gray(` ${opt.description}`)); + } + // Display additional details if provided + if (opt.details && opt.details.length > 0) { + opt.details.forEach((detail) => { + console.log(chalk.dim(` • ${detail}`)); + }); + } }); console.log(chalk.gray(` 0. Cancel`)); console.log();