Merge pull request #2 from nrslib/feature/remove-interactive-mode

Remove interactive mode and simplify CLI
This commit is contained in:
nrslib 2026-01-25 21:59:39 +09:00 committed by GitHub
commit 8925609d9a
33 changed files with 194 additions and 4063 deletions

View File

@ -115,4 +115,12 @@ steps:
- Strict TypeScript with `noUncheckedIndexedAccess` - Strict TypeScript with `noUncheckedIndexedAccess`
- Zod schemas for runtime validation (`src/models/schemas.ts`) - Zod schemas for runtime validation (`src/models/schemas.ts`)
- Uses `@anthropic-ai/claude-agent-sdk` for Claude integration - 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

649
package-lock.json generated
View File

@ -1,31 +1,27 @@
{ {
"name": "wolf-orchestrator", "name": "takt",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wolf-orchestrator", "name": "takt",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.19", "@anthropic-ai/claude-agent-sdk": "^0.2.19",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.1.0", "commander": "^12.1.0",
"ink": "^5.2.1",
"ink-spinner": "^5.0.0",
"react": "^18.3.1",
"yaml": "^2.4.5", "yaml": "^2.4.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"bin": { "bin": {
"wolf": "bin/wolf", "takt": "bin/takt",
"wolf-cli": "dist/cli.js" "takt-cli": "dist/cli.js"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/react": "^18.3.27",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
@ -37,31 +33,6 @@
"node": ">=18.0.0" "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": { "node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.2.19", "version": "0.2.19",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.19.tgz", "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" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.1", "version": "8.53.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", "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" "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": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -1846,18 +1772,6 @@
"node": ">=12" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1934,101 +1848,6 @@
"node": ">= 16" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2065,15 +1884,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2089,13 +1899,6 @@
"node": ">= 8" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2131,24 +1934,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -2156,16 +1941,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "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": "^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": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -2659,94 +2422,6 @@
"node": ">=0.8.19" "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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -2757,18 +2432,6 @@
"node": ">=0.10.0" "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": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -2782,21 +2445,6 @@
"node": ">=0.10.0" "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": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -2804,12 +2452,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@ -2891,18 +2533,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -2920,15 +2550,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -2978,21 +2599,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3056,15 +2662,6 @@
"node": ">=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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3171,34 +2768,6 @@
"node": ">=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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3209,22 +2778,6 @@
"node": ">=4" "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": { "node_modules/rollup": {
"version": "4.56.0", "version": "4.56.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
@ -3270,15 +2823,6 @@
"fsevents": "~2.3.2" "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": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -3322,55 +2866,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3381,27 +2876,6 @@
"node": ">=0.10.0" "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": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -3416,38 +2890,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -3561,18 +3003,6 @@
"node": ">= 0.8.0" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -3810,21 +3240,6 @@
"node": ">=8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -3835,56 +3250,6 @@
"node": ">=0.10.0" "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": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
@ -3913,12 +3278,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@ -20,7 +20,7 @@ vi.mock('node:os', async () => {
}); });
// Mock the prompt to avoid interactive input // Mock the prompt to avoid interactive input
vi.mock('../interactive/prompt.js', () => ({ vi.mock('../prompt/index.js', () => ({
selectOptionWithDefault: vi.fn().mockResolvedValue('ja'), selectOptionWithDefault: vi.fn().mockResolvedValue('ja'),
})); }));

View File

@ -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>): 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('');
});
});

View File

@ -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> = {}): 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);
});
});
});

View File

@ -100,10 +100,11 @@ export async function runCustomAgent(
// Custom agent with prompt // Custom agent with prompt
const systemPrompt = loadAgentPrompt(agentConfig); const systemPrompt = loadAgentPrompt(agentConfig);
const tools = agentConfig.allowedTools || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'];
const callOptions: ClaudeCallOptions = { const callOptions: ClaudeCallOptions = {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: agentConfig.allowedTools || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'], allowedTools: tools,
model: options.model || agentConfig.model, model: options.model || agentConfig.model,
statusPatterns: agentConfig.statusPatterns, statusPatterns: agentConfig.statusPatterns,
onStream: options.onStream, onStream: options.onStream,

View File

@ -10,6 +10,7 @@
* takt /switch - Switch workflow interactively * takt /switch - Switch workflow interactively
* takt /clear - Clear agent conversation sessions * takt /clear - Clear agent conversation sessions
* takt /help - Show help * takt /help - Show help
* takt /config - Select permission mode interactively
*/ */
import { Command } from 'commander'; import { Command } from 'commander';
@ -27,9 +28,10 @@ import {
runAllTasks, runAllTasks,
showHelp, showHelp,
switchWorkflow, switchWorkflow,
switchConfig,
} from './commands/index.js'; } from './commands/index.js';
import { listWorkflows } from './config/workflowLoader.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'; import { DEFAULT_WORKFLOW_NAME } from './constants.js';
const log = createLogger('cli'); const log = createLogger('cli');
@ -97,9 +99,13 @@ program
showHelp(); showHelp();
return; return;
case 'config':
await switchConfig(cwd, args[0]);
return;
default: default:
error(`Unknown command: /${command}`); error(`Unknown command: /${command}`);
info('Available: /run-tasks, /switch, /clear, /help'); info('Available: /run-tasks, /switch, /clear, /help, /config');
process.exit(1); process.exit(1);
} }
} }

141
src/commands/config.ts Normal file
View File

@ -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<boolean> {
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;
}

View File

@ -7,3 +7,4 @@ export { executeTask, runAllTasks, type ExecuteTaskOptions } from './taskExecuti
export { showHelp } from './help.js'; export { showHelp } from './help.js';
export { withAgentSession } from './session.js'; export { withAgentSession } from './session.js';
export { switchWorkflow } from './workflow.js'; export { switchWorkflow } from './workflow.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';

View File

@ -5,7 +5,7 @@
import { listWorkflows, loadWorkflow, getBuiltinWorkflow } from '../config/index.js'; import { listWorkflows, loadWorkflow, getBuiltinWorkflow } from '../config/index.js';
import { getCurrentWorkflow, setCurrentWorkflow } from '../config/paths.js'; import { getCurrentWorkflow, setCurrentWorkflow } from '../config/paths.js';
import { info, success, error } from '../utils/ui.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 * Get all available workflow options

View File

@ -8,7 +8,7 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import type { Language } from '../models/types.js'; import type { Language } from '../models/types.js';
import { DEFAULT_LANGUAGE } from '../constants.js'; import { DEFAULT_LANGUAGE } from '../constants.js';
import { selectOptionWithDefault } from '../interactive/prompt.js'; import { selectOptionWithDefault } from '../prompt/index.js';
import { import {
getGlobalConfigDir, getGlobalConfigDir,
getGlobalAgentsDir, getGlobalAgentsDir,

View File

@ -8,11 +8,24 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { parse, stringify } from 'yaml'; 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 */ /** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig { export interface ProjectLocalConfig {
/** Current workflow name */ /** Current workflow name */
workflow?: string; 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; sacrificeMode?: boolean;
/** Verbose output mode */ /** Verbose output mode */
verbose?: boolean; verbose?: boolean;
@ -23,6 +36,7 @@ export interface ProjectLocalConfig {
/** Default project configuration */ /** Default project configuration */
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {
workflow: 'default', workflow: 'default',
permissionMode: 'default',
}; };
/** /**

View File

@ -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 <name> - 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 <name>');
return { continue: true };
}
const agentName = args[0];
if (!agentName) {
error('Usage: /agent <name>');
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 };
}
)
);

View File

@ -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 };
})
);

View File

@ -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';

View File

@ -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<CommandResult>;
}
/** Command registry */
class CommandRegistry {
private commands: Map<string, Command> = 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 };
}

View File

@ -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 };
})
);

View File

@ -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<TaskResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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 };
}
)
);

View File

@ -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 };
})
);

View File

@ -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 EscEnter 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;
}
}

View File

@ -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<Record<string, string>> => {
const answers: Record<string, string> = {};
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<typeof createReadlineInterface>,
historyManager: InputHistoryManager
): AskUserQuestionHandler {
return async (input: AskUserQuestionInput): Promise<Record<string, string>> => {
const answers: Record<string, string> = {};
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<string>((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<string>((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<string>((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<Record<string, string>> => {
info('[SACRIFICE MODE] Auto-skipping AskUserQuestion');
return {};
};
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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('');
}
};
}

View File

@ -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<string> {
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();
});
}

View File

@ -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;
}

View File

@ -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<string>;
/** 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, unknown>): 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, unknown>): 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, unknown>): 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<string, unknown>): 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<PermissionResult> => {
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<string>((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<PermissionResult> => {
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`,
};
};
}

View File

@ -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<typeof createReadlineInterface>
): Promise<boolean> {
// 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<typeof createReadlineInterface>
): Promise<boolean> {
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<void> {
// 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<void> => {
// 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();
}

View File

@ -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;
}

View File

@ -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 <name> Run a single agent with next input
${chalk.bold.yellow('Task Execution:')}
/task, /t Show task list
/task run Execute next task
/task run <name> 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<string | null> {
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);
}
});
});
}

View File

@ -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<typeof createReadlineInterface>,
historyManager: InputHistoryManager
): Promise<string | null> {
// 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<typeof createReadlineInterface>,
historyManager: InputHistoryManager
): Promise<number | null> {
// 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);
});
});
}

View File

@ -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<typeof createReadlineInterface>,
requestedIterations: number = 10
): Promise<string> {
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<ReturnType<StreamDisplay['createHandler']>>[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<string | null> => {
// 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<number | null> => {
// 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;
}
}

View File

@ -13,7 +13,7 @@ import chalk from 'chalk';
*/ */
export async function selectOption<T extends string>( export async function selectOption<T extends string>(
message: string, message: string,
options: { label: string; value: T }[] options: { label: string; value: T; description?: string; details?: string[] }[]
): Promise<T | null> { ): Promise<T | null> {
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
@ -26,6 +26,16 @@ export async function selectOption<T extends string>(
options.forEach((opt, idx) => { options.forEach((opt, idx) => {
console.log(chalk.yellow(` ${idx + 1}. `) + opt.label); 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(chalk.gray(` 0. Cancel`));
console.log(); console.log();