diff --git a/bun.lock b/bun.lock index 5fd864ca9f..6d091f51d6 100644 --- a/bun.lock +++ b/bun.lock @@ -443,8 +443,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "0.1.101", - "@opentui/solid": "0.1.101", + "@opentui/core": "0.1.102", + "@opentui/solid": "0.1.102", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -544,16 +544,16 @@ "zod": "catalog:", }, "devDependencies": { - "@opentui/core": "0.1.101", - "@opentui/solid": "0.1.101", + "@opentui/core": "0.1.102", + "@opentui/solid": "0.1.102", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.1.101", - "@opentui/solid": ">=0.1.101", + "@opentui/core": ">=0.1.102", + "@opentui/solid": ">=0.1.102", }, "optionalPeers": [ "@opentui/core", @@ -755,8 +755,8 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.1.99", - "@opentui/solid": "0.1.99", + "@opentui/core": "0.1.102", + "@opentui/solid": "0.1.102", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1916,21 +1916,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.1.101", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.101", "@opentui/core-darwin-x64": "0.1.101", "@opentui/core-linux-arm64": "0.1.101", "@opentui/core-linux-x64": "0.1.101", "@opentui/core-win32-arm64": "0.1.101", "@opentui/core-win32-x64": "0.1.101", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8jUhNKnwCDO3Y2iiEmagoQLjgX5l1WbddQiwky8B5JU4FW0/WRHairBmU1kRAQBmhdeg57dVinSG4iu2PAtKEA=="], + "@opentui/core": ["@opentui/core@0.1.102", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.102", "@opentui/core-darwin-x64": "0.1.102", "@opentui/core-linux-arm64": "0.1.102", "@opentui/core-linux-x64": "0.1.102", "@opentui/core-win32-arm64": "0.1.102", "@opentui/core-win32-x64": "0.1.102", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-gNbU4XnSifo429nZ6T4jcxSmp5pFDrh0AsGJ73Vmlpc4YVWnLJ25RGsZXRsJhvxUK9OtAWJj2wq/kmiFcPLBCw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.101", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HtqZh8TIKCH1Nge5J0etBCpzYfPY4fVcq110uJm2As6D/dTTPv8r4J+KkrqoSphkpj/Y2b4t7KpqNHthXA0EVw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.102", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vAYNfWhCIGnGGLRu4janrdE4z/WGeRVVJ7rRRmxWliFZfn+i29Pvrc+orKo0He6UO9nB1dLVIhmW8dlINPvhgQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.101", "", { "os": "darwin", "cpu": "x64" }, "sha512-o5ClQWnGG1inRE2YZAatPw1jPEAJni00amcoIfKBj8e1WS+fQA+iQTq1xFunNcyNPObLDCVuW1X+NrbK9xmPvQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.102", "", { "os": "darwin", "cpu": "x64" }, "sha512-BSiZx0QzQ+MUm+XMrktBMyYQKifvy4FFvxqVIhKJv9xlHTTdsFtN/RZ/vuExLn6Xxv9d19psqSbTEJ+HGe76mw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.101", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/weY7DQpaPWGYDPD0CROHowUotqnVlk7Kb6l9+iZCrxm9s7HPRHkcMDVmcWDqHEqa/J879EJcqaUDzDArqC+w=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.102", "", { "os": "linux", "cpu": "arm64" }, "sha512-/49i8RnBQ28o30G6/5Ni4CCtgwOzGn3/NzJbUcfIctXMBiNdoH3v1iSS+XlUREolMNz7l1zcJ6+9OvCRaV7DdA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.101", "", { "os": "linux", "cpu": "x64" }, "sha512-+Bfr8jLbbR1WREUMCCvSZ44G1+WU2lPqJx7x1StTa9iFNEdicxCdd0QQsO6cnKn5yW+2Pr/FdrqHbxSQw3ejbA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.102", "", { "os": "linux", "cpu": "x64" }, "sha512-XpkQ+nUaa4HY6poJqfJEkSFil5CDyXzpQsnU5nUAwJXrHLUJOh8Gwx+F1UdoD/JNwBFlJ1Xhzpp3CdmuFXPdyQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.101", "", { "os": "win32", "cpu": "arm64" }, "sha512-LTMIHJzJrVqS8mgpp+tuyVHuqYlicQTvFi/sTsJ6Xswf1asatsvZYsbQByhBLpFT80j10G7uvDa361S5gjCUDA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.102", "", { "os": "win32", "cpu": "arm64" }, "sha512-AeoCvtlb/RhDRukFDSmblT5EJFvSwftRp2DGEwZ1leu+zkmp0Ffykn3mGWR2UQUyPTVOGRWKGGyp/Kg2SeniAA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.101", "", { "os": "win32", "cpu": "x64" }, "sha512-VaMs5bg6y0tYKptaEK8Hy5wTp4m//wJRKUdW8uvrS9cFgxyovZGuw0+TfK3NgbdeX+8jWm8LEAiak4jle5BABg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.102", "", { "os": "win32", "cpu": "x64" }, "sha512-9Lzd+XkLOJRqhe54bDdorviHAFuO1SPIQMqPieE4bszkiUwekEdgg5x9ymwHh5OUCh0Wiwstl42G4f7BjvMIcA=="], - "@opentui/solid": ["@opentui/solid@0.1.101", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.101", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-STY2FQYtVS2rhUgpslG6mM0EAkgobBDF91+B+SNmvXIkJwP+ydP6UVgcuIo5McIbb9GIbAODx5X2Q48PSR7hgw=="], + "@opentui/solid": ["@opentui/solid@0.1.102", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.102", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-Tgy2QXROyRvJw+8gS282ZOdk9mrAZar9ECQgEBf4V6kCkgAAUAk5OymbqK3GQFIbuMW+Bouv2Rc8YuPEzJC4tg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -5966,7 +5966,7 @@ "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], - "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], @@ -6366,6 +6366,8 @@ "@expo/package-manager/npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], + "@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "@expo/prebuild-config/xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], "@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -6732,6 +6734,8 @@ "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -7114,6 +7118,8 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -7292,8 +7298,6 @@ "xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], @@ -7544,8 +7548,6 @@ "@azure/core-http/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "@azure/core-http/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "@azure/core-xml/fast-xml-parser/strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], "@azure/identity/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -7604,8 +7606,6 @@ "@expo/config-plugins/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "@expo/config-plugins/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.32.1", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="], "@expo/metro/metro-source-map/ob1": ["ob1@0.83.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-vNKPYC8L5ycVANANpF/S+WZHpfnRWKx/F3AYP4QMn6ZJTh+l2HOrId0clNkEmua58NB9vmI9Qh7YOoV/4folYg=="], @@ -7620,8 +7620,6 @@ "@expo/prebuild-config/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "@expo/prebuild-config/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -8116,8 +8114,6 @@ "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "parse-bmfont-xml/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], diff --git a/package.json b/package.json index 1bc2a689e9..94a0baf317 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.1.99", - "@opentui/solid": "0.1.99", + "@opentui/core": "0.1.102", + "@opentui/solid": "0.1.102", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index aa3f1156d5..9c42dc56f9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -124,8 +124,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "0.1.101", - "@opentui/solid": "0.1.101", + "@opentui/core": "0.1.102", + "@opentui/solid": "0.1.102", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index ed962ffa51..c749f384e2 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,46 +1,48 @@ +// CLI entry point for `opencode run`. +// +// Handles three modes: +// 1. Non-interactive (default): sends a single prompt, streams events to +// stdout, and exits when the session goes idle. +// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// with an in-process server (no external HTTP). +// 3. Interactive attach (`--interactive --attach`): connects to a running +// opencode server and runs interactive mode against it. +// +// Also supports `--command` for slash-command execution, `--format json` for +// raw event streaming, `--continue` / `--session` for session resumption, +// and `--fork` for forking before continuing. import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" import { UI } from "../ui" import { cmd } from "./cmd" -import { Flag } from "../../flag/flag" +import { Flag } from "@/flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" -import { Filesystem } from "../../util" +import { Filesystem } from "@/util" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { Server } from "../../server/server" -import { Provider } from "../../provider" -import { Agent } from "../../agent/agent" -import { Permission } from "../../permission" -import { Tool } from "../../tool" -import { GlobTool } from "../../tool/glob" -import { GrepTool } from "../../tool/grep" -import { ReadTool } from "../../tool/read" -import { WebFetchTool } from "../../tool/webfetch" -import { EditTool } from "../../tool/edit" -import { WriteTool } from "../../tool/write" -import { CodeSearchTool } from "../../tool/codesearch" -import { WebSearchTool } from "../../tool/websearch" -import { TaskTool } from "../../tool/task" -import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" -import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "../../util" +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" import { AppRuntime } from "@/effect/app-runtime" +import type { RunDemo } from "./run/types" -type ToolProps = { - input: Tool.InferParameters - metadata: Tool.InferMetadata - part: ToolPart +const runtimeTask = import("./run/runtime") +type ModelInput = Parameters[0]["model"] + +function pick(value: string | undefined): ModelInput | undefined { + if (!value) return undefined + const [providerID, ...rest] = value.split("/") + return { + providerID, + modelID: rest.join("/"), + } as ModelInput } -function props(part: ToolPart): ToolProps { - const state = part.state - return { - input: state.input as Tool.InferParameters, - metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, - part, - } +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +51,12 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: string + directory?: string +} + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -62,152 +70,22 @@ function block(info: Inline, output?: string) { UI.empty() } -function fallback(part: ToolPart) { - const state = part.state - const input = "input" in state ? state.input : undefined - const title = - ("title" in state && state.title ? state.title : undefined) || - (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ - icon: "⚙", - title: `${part.tool} ${title}`, - }) -} +async function tool(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + if (next.mode === "block") { + block(next, next.body) + return + } -function glob(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Glob "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.count - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function grep(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Grep "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.matches - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function read(info: ToolProps) { - const file = normalizePath(info.input.filePath) - const pairs = Object.entries(info.input).filter(([key, value]) => { - if (key === "filePath") return false - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" - }) - const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined - inline({ - icon: "→", - title: `Read ${file}`, - ...(description && { description }), - }) -} - -function write(info: ToolProps) { - block( - { - icon: "←", - title: `Write ${normalizePath(info.input.filePath)}`, - }, - info.part.state.status === "completed" ? info.part.state.output : undefined, - ) -} - -function webfetch(info: ToolProps) { - inline({ - icon: "%", - title: `WebFetch ${info.input.url}`, - }) -} - -function edit(info: ToolProps) { - const title = normalizePath(info.input.filePath) - const diff = info.metadata.diff - block( - { - icon: "←", - title: `Edit ${title}`, - }, - diff, - ) -} - -function codesearch(info: ToolProps) { - inline({ - icon: "◇", - title: `Exa Code Search "${info.input.query}"`, - }) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `Exa Web Search "${info.input.query}"`, - }) -} - -function task(info: ToolProps) { - const input = info.part.state.input - const status = info.part.state.status - const subagent = - typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown" - const agent = Locale.titlecase(subagent) - const desc = - typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined - const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓" - const name = desc ?? `${agent} Task` - inline({ - icon, - title: name, - description: desc ? `${agent} Agent` : undefined, - }) -} - -function skill(info: ToolProps) { - inline({ - icon: "→", - title: `Skill "${info.input.name}"`, - }) -} - -function bash(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - -function todo(info: ToolProps) { - block( - { - icon: "#", - title: "Todos", - }, - info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), - ) -} - -function normalizePath(input?: string) { - if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input + inline(next) + } catch { + inline({ + icon: "⚙", + title: part.tool, + }) + } } export const RunCommand = cmd({ @@ -292,6 +170,11 @@ export const RunCommand = cmd({ .option("thinking", { type: "boolean", describe: "show thinking blocks", + }) + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", default: false, }) .option("dangerously-skip-permissions", { @@ -299,30 +182,87 @@ export const RunCommand = cmd({ describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, }) + .option("demo", { + type: "string", + choices: ["on", "permission", "question", "mix", "text"], + describe: "enable direct interactive demo slash commands", + }) + .option("demo-text", { + type: "string", + describe: "text used with --demo text", + }) }, handler: async (args) => { + const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") + const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const die = (message: string): never => { + UI.error(message) + process.exit(1) + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") + if (args.interactive && args.command) { + die("--interactive cannot be used with --command") + } + + if (args.demo && !args.interactive) { + die("--demo requires --interactive") + } + + if (args.demoText && args.demo !== "text") { + die("--demo-text requires --demo text") + } + + if (args.interactive && args.format === "json") { + die("--interactive cannot be used with --format json") + } + + if (args.interactive && !process.stdin.isTTY) { + die("--interactive requires a TTY") + } + + if (args.interactive && !process.stdout.isTTY) { + die("--interactive requires a TTY stdout") + } + + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { - if (!args.dir) return undefined + if (!args.dir) return args.attach ? undefined : root if (args.attach) return args.dir + try { - process.chdir(args.dir) + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = (() => { + if (!args.attach) return undefined + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return { Authorization: auth } + })() + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -341,7 +281,7 @@ export const RunCommand = cmd({ if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - if (message.trim().length === 0 && !args.command) { + if (message.trim().length === 0 && !args.command && !args.interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -351,28 +291,30 @@ export const RunCommand = cmd({ process.exit(1) } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - { - permission: "edit", - action: "allow", - pattern: "*", - }, - ] + const rules: Permission.Ruleset = args.interactive + ? [] + : [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + { + permission: "edit", + action: "allow", + pattern: "*", + }, + ] function title() { if (args.title === undefined) return @@ -380,19 +322,83 @@ export const RunCommand = cmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await sdk.session + .get({ + sessionID: args.session, + }) + .catch(() => undefined) - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + if (args.fork) { + const forked = await sdk.session.fork({ + sessionID: args.session, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, + } + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + } } - if (baseID) return baseID + const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + + if (base && args.fork) { + const forked = await sdk.session.fork({ + sessionID: base.id, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, + } + } + + if (base) { + return { + id: base.id, + title: base.title, + directory: base.directory, + } + } const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + const result = await sdk.session.create({ + title: name, + permission: rules, + }) + const id = result.data?.id + if (!id) { + return + } + + return { + id, + title: result.data?.title ?? name, + directory: result.data?.directory, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -410,44 +416,131 @@ export const RunCommand = cmd({ } } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === "bash") return bash(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "codesearch") return codesearch(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root } + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + + async function localAgent() { + if (!args.agent) return undefined + const name = args.agent + + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + const name = args.agent + + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + + async function execute(sdk: OpencodeClient) { + const sess = await session(sdk) + if (!sess?.id) { + UI.error("Session not found") + process.exit(1) + } + const sessionID = sess.id + function emit(type: string, data: Record) { if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + process.stdout.write( + JSON.stringify({ + type, + timestamp: Date.now(), + sessionID, + ...data, + }) + EOL, + ) return true } return false } - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { + // Consume one subscribed event stream for the active session and mirror it + // to stdout/UI. `client` is passed explicitly because attach mode may + // rebind the SDK to the session's directory after the subscription is + // created, and replies issued from inside the loop must use that client. + async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() + let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && + event.properties.sessionID === sessionID && event.properties.info.role === "assistant" && args.format !== "json" && toggles.get("start") !== true @@ -465,7 +558,7 @@ export const RunCommand = cmd({ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { - tool(part) + await tool(part) continue } inline({ @@ -482,7 +575,7 @@ export const RunCommand = cmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -547,7 +640,7 @@ export const RunCommand = cmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -557,7 +650,7 @@ export const RunCommand = cmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -565,121 +658,106 @@ export const RunCommand = cmd({ } } } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + const agent = await pickAgent(client) - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + await share(client, sessionID) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } - - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } - - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, + if (!args.interactive) { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { + console.error(e) + process.exit(1) }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + + if (args.command) { + await client.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return + } + + const model = pick(args.model) + await client.session.prompt({ sessionID, agent, model, variant: args.variant, parts: [...files, { type: "text", text: message }], }) + return } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + await runInteractiveMode({ + sdk: client, + directory: cwd, + sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session || args.continue) && !args.fork, + agent, + model, + variant: args.variant, + files, + initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined, + thinking, + demo: args.demo as RunDemo | undefined, + demoText: args.demoText, + }) + return } - if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) - } - - await bootstrap(process.cwd(), async () => { + if (args.interactive && !args.attach && !args.session && !args.continue) { + const model = pick(args.model) + const { runInteractiveLocalMode } = await runtimeTask const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") const request = new Request(input, init) return Server.Default().app.fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + + return await runInteractiveLocalMode({ + directory: directory ?? root, + fetch: fetchFn, + resolveAgent: localAgent, + session, + share, + agent: args.agent, + model, + variant: args.variant, + files, + initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined, + thinking, + demo: args.demo as RunDemo | undefined, + demoText: args.demoText, + }) + } + + if (args.attach) { + const sdk = attachSDK(directory) + return await execute(sdk) + } + + await bootstrap(directory ?? root, async () => { + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory, + }) await execute(sdk) }) }, diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts new file mode 100644 index 0000000000..86fbdc9264 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1295 @@ +// Demo mode for testing direct interactive mode without a real SDK. +// +// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic +// SDK events that feed through the real reducer and footer pipeline. This +// lets you test scrollback formatting, permission UI, question UI, and tool +// snapshots without making actual model calls. +// +// Slash commands: +// /permission [kind] → triggers a permission request variant +// /question [kind] → triggers a question request variant +// /fmt → emits a specific tool/text type (text, reasoning, bash, +// write, edit, patch, task, todo, question, error, mix) +// +// Demo mode also handles permission and question replies locally, completing +// or failing the synthetic tool parts as appropriate. +import path from "path" +import type { Event, ToolPart } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunDemo, + RunPrompt, + StreamCommit, +} from "./types" + +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] +const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const QUESTIONS = ["multi", "single", "checklist", "custom"] as const + +type PermissionKind = (typeof PERMISSIONS)[number] +type QuestionKind = (typeof QUESTIONS)[number] + +function permissionKind(value: string | undefined): PermissionKind | undefined { + const next = (value || "edit").toLowerCase() + return PERMISSIONS.find((item) => item === next) +} + +function questionKind(value: string | undefined): QuestionKind | undefined { + const next = (value || "multi").toLowerCase() + return QUESTIONS.find((item) => item === next) +} + +const SAMPLE_MARKDOWN = [ + "# Direct Mode Demo", + "", + "This is a realistic assistant response for direct-mode formatting checks.", + "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.", + "", + "## Summary", + "", + "- Restored the final markdown flush so the last block is committed on idle.", + "- Switched markdown scrollback commits back to top-level block boundaries.", + "- Added footer-level regression coverage for split-footer rendering.", + "", + "## Status", + "", + "| Area | Before | After | Notes |", + "| --- | --- | --- | --- |", + "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |", + "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |", + "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |", + "", + "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.", + "", + "```ts", + "const result = { markdown: true, tables: 2, stable: true }", + "```", + "", + "## Files", + "", + "| File | Change |", + "| --- | --- |", + "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |", + "| `footer.ts` | Keep active surfaces across footer-height-only resizes |", + "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |", + "", + "Next step: run `/fmt table` if you want a tighter table-only sample.", +].join("\n") + +const SAMPLE_TABLE = [ + "# Table Sample", + "", + "| Kind | Example | Notes |", + "| --- | --- | --- |", + "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |", + "| Unicode | `漢字` | Wide characters should remain aligned |", + "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |", + "| Status | done | Final row should still appear after idle |", +].join("\n") + +type Ref = { + msg: string + part: string + call: string + tool: string + input: Record + start: number +} + +type Ask = { + ref: Ref +} + +type Perm = { + ref: Ref + done: { + title: string + output: string + metadata?: Record + } +} + +type Permit = { + ref: Ref + permission: string + patterns: string[] + metadata?: Record + always: string[] + done: Perm["done"] +} + +type State = { + id: string + thinking: boolean + data: SessionData + footer: FooterApi + limits: () => Record + msg: number + part: number + call: number + perm: number + ask: number + perms: Map + asks: Map +} + +type Input = { + mode: RunDemo + text?: string + sessionID: string + thinking: boolean + limits: () => Record + footer: FooterApi +} + +function note(footer: FooterApi, text: string): void { + footer.append({ + kind: "system", + text, + phase: "start", + source: "system", + }) +} + +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!signal) { + setTimeout(resolve, ms) + return + } + + if (signal.aborted) { + resolve() + return + } + + const done = () => { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + + const timer = setTimeout(() => { + signal.removeEventListener("abort", done) + resolve() + }, ms) + + signal.addEventListener("abort", done, { once: true }) + }) +} + +function split(text: string): string[] { + if (text.length <= 48) { + return [text] + } + + const size = Math.ceil(text.length / 3) + return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] +} + +function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { + state[key] += 1 + return `demo_${prefix}_${state[key]}` +} + +function feed(state: State, event: Event): void { + const out = reduceSessionData({ + data: state.data, + event, + sessionID: state.id, + thinking: state.thinking, + limits: state.limits(), + }) + state.data = out.data + writeSessionOutput( + { + footer: state.footer, + }, + out, + ) +} + +function open(state: State): string { + const id = take(state, "msg", "msg") + feed(state, { + type: "message.updated", + properties: { + sessionID: state.id, + info: { + id, + sessionID: state.id, + role: "assistant", + time: { + created: Date.now(), + }, + parentID: `user_${id}`, + modelID: "demo", + providerID: "demo", + mode: "demo", + agent: "demo", + path: { + cwd: process.cwd(), + root: process.cwd(), + }, + cost: 0.001, + tokens: { + input: 120, + output: 320, + reasoning: 80, + cache: { + read: 0, + write: 0, + }, + }, + }, + }, + } as Event) + return id +} + +async function emitText(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +function make(state: State, tool: string, input: Record): Ref { + return { + msg: open(state), + part: take(state, "part", "part"), + call: take(state, "call", "call"), + tool, + input, + start: Date.now(), + } +} + +function startTool(state: State, ref: Ref, metadata: Record = {}): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "running", + input: ref.input, + metadata, + time: { + start: ref.start, + }, + }, + }, + }, + } as Event) +} + +function askPermission(state: State, item: Permit): void { + startTool(state, item.ref) + + const id = take(state, "perm", "perm") + state.perms.set(id, { + ref: item.ref, + done: item.done, + }) + + feed(state, { + type: "permission.asked", + properties: { + id, + sessionID: state.id, + permission: item.permission, + patterns: item.patterns, + metadata: item.metadata ?? {}, + always: item.always, + tool: { + messageID: item.ref.msg, + callID: item.ref.call, + }, + }, + } as Event) +} + +function doneTool( + state: State, + ref: Ref, + output: { + title: string + output: string + metadata?: Record + }, +): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + input: ref.input, + output: output.output, + title: output.title, + metadata: output.metadata ?? {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function failTool(state: State, ref: Ref, error: string): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function emitError(state: State, text: string): void { + const event = { + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "UnknownError", + data: { + message: text, + }, + }, + }, + } satisfies Event + feed(state, event) +} + +async function emitBash(state: State, signal?: AbortSignal): Promise { + const ref = make(state, "bash", { + command: "git status", + workdir: process.cwd(), + description: "Show git status", + }) + startTool(state, ref) + await wait(70, signal) + doneTool(state, ref, { + title: "git status", + output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, + metadata: { + exitCode: 0, + }, + }) +} + +function emitWrite(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "write", { + filePath: file, + content: "export const demo = 42\n", + }) + doneTool(state, ref, { + title: "write", + output: "", + metadata: {}, + }) +} + +function emitEdit(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "edit", { + filePath: file, + }) + doneTool(state, ref, { + title: "edit", + output: "", + metadata: { + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + }) +} + +function emitPatch(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "apply_patch", { + patchText: "*** Begin Patch\n*** End Patch", + }) + doneTool(state, ref, { + title: "apply_patch", + output: "", + metadata: { + files: [ + { + type: "update", + filePath: file, + relativePath: "src/demo-format.ts", + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + deletions: 1, + }, + { + type: "add", + filePath: path.join(process.cwd(), "README-demo.md"), + relativePath: "README-demo.md", + diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + deletions: 0, + }, + ], + }, + }) +} + +function emitTask(state: State): void { + const ref = make(state, "task", { + description: "Scan run/* for reducer touchpoints", + subagent_type: "explore", + }) + doneTool(state, ref, { + title: "Reducer touchpoints found", + output: "", + metadata: { + toolcalls: 4, + sessionId: "sub_demo_1", + }, + }) + const part = { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } satisfies ToolPart + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) +} + +function emitTodo(state: State): void { + const ref = make(state, "todowrite", { + todos: [ + { + content: "Trigger permission UI", + status: "completed", + }, + { + content: "Trigger question UI", + status: "in_progress", + }, + { + content: "Tune tool formatting", + status: "pending", + }, + ], + }) + doneTool(state, ref, { + title: "todowrite", + output: "", + metadata: {}, + }) +} + +function emitQuestionTool(state: State): void { + const ref = make(state, "question", { + questions: [ + { + header: "Style", + question: "Which output style do you want to inspect?", + options: [ + { label: "Diff", description: "Show diff block" }, + { label: "Code", description: "Show code block" }, + ], + multiple: false, + }, + { + header: "Extras", + question: "Pick extra rows", + options: [ + { label: "Usage", description: "Add usage row" }, + { label: "Duration", description: "Add duration row" }, + ], + multiple: true, + custom: true, + }, + ], + }) + doneTool(state, ref, { + title: "question", + output: "", + metadata: { + answers: [["Diff"], ["Usage", "custom-note"]], + }, + }) +} + +function emitPermission(state: State, kind: PermissionKind = "edit"): void { + const root = process.cwd() + const file = path.join(root, "src", "demo-format.ts") + + if (kind === "bash") { + const command = "git status --short" + const ref = make(state, "bash", { + command, + workdir: root, + description: "Inspect worktree changes", + }) + askPermission(state, { + ref, + permission: "bash", + patterns: [command], + always: ["*"], + done: { + title: "git status --short", + output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, + metadata: { + exitCode: 0, + }, + }, + }) + return + } + + if (kind === "read") { + const target = path.join(root, "package.json") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 80, + }) + askPermission(state, { + ref, + permission: "read", + patterns: [target], + always: [target], + done: { + title: "read", + output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), + metadata: {}, + }, + }) + return + } + + if (kind === "task") { + const ref = make(state, "task", { + description: "Inspect footer spacing across direct-mode prompts", + subagent_type: "explore", + }) + askPermission(state, { + ref, + permission: "task", + patterns: ["explore"], + always: ["*"], + done: { + title: "Footer spacing checked", + output: "", + metadata: { + toolcalls: 3, + sessionId: "sub_demo_perm_1", + }, + }, + }) + return + } + + if (kind === "external") { + const dir = path.join(path.dirname(root), "demo-shared") + const target = path.join(dir, "README.md") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 40, + }) + askPermission(state, { + ref, + permission: "external_directory", + patterns: [`${dir}/**`], + metadata: { + parentDir: dir, + filepath: target, + }, + always: [`${dir}/**`], + done: { + title: "read", + output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, + metadata: {}, + }, + }) + return + } + + if (kind === "doom") { + const ref = make(state, "task", { + description: "Retry the formatter after repeated failures", + subagent_type: "general", + }) + askPermission(state, { + ref, + permission: "doom_loop", + patterns: ["*"], + always: ["*"], + done: { + title: "Retry allowed", + output: "Continuing after repeated failures.\n", + metadata: {}, + }, + }) + return + } + + const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" + const ref = make(state, "edit", { + filePath: file, + filepath: file, + diff, + }) + askPermission(state, { + ref, + permission: "edit", + patterns: [file], + always: [file], + done: { + title: "edit", + output: "", + metadata: { + diff, + }, + }, + }) +} + +function emitQuestion(state: State, kind: QuestionKind = "multi"): void { + const questions = + kind === "single" + ? [ + { + header: "Mode", + question: "Which footer should be the reference for spacing checks?", + options: [ + { label: "Permission", description: "Inspect the permission footer" }, + { label: "Question", description: "Keep this question footer open" }, + { label: "Prompt", description: "Return to the normal composer" }, + ], + multiple: false, + custom: false, + }, + ] + : kind === "checklist" + ? [ + { + header: "Checks", + question: "Select the direct-mode cases you want to inspect next", + options: [ + { label: "Diff", description: "Show an edit diff in the footer" }, + { label: "Task", description: "Show a structured task summary" }, + { label: "Todo", description: "Show a todo snapshot" }, + { label: "Error", description: "Show an error transcript row" }, + ], + multiple: true, + custom: false, + }, + ] + : kind === "custom" + ? [ + { + header: "Reply", + question: "What custom answer should appear in the footer preview?", + options: [ + { label: "Short note", description: "Keep the answer to one line" }, + { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, + ], + multiple: false, + custom: true, + }, + ] + : [ + { + header: "Layout", + question: "Which footer view should stay active while testing?", + options: [ + { label: "Prompt", description: "Return to prompt" }, + { label: "Question", description: "Keep question open" }, + ], + multiple: false, + }, + { + header: "Rows", + question: "Pick formatting previews", + options: [ + { label: "Diff", description: "Emit edit diff" }, + { label: "Task", description: "Emit task card" }, + { label: "Todo", description: "Emit todo card" }, + ], + multiple: true, + custom: true, + }, + ] + + const ref = make(state, "question", { questions }) + startTool(state, ref) + + const id = take(state, "ask", "ask") + state.asks.set(id, { ref }) + + feed(state, { + type: "question.asked", + properties: { + id, + sessionID: state.id, + questions, + tool: { + messageID: ref.msg, + callID: ref.call, + }, + }, + } as Event) +} + +async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { + if (kind === "text") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "markdown" || kind === "md") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "table") { + await emitText(state, body || SAMPLE_TABLE, signal) + return true + } + + if (kind === "reasoning") { + await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal) + return true + } + + if (kind === "bash") { + await emitBash(state, signal) + return true + } + + if (kind === "write") { + emitWrite(state) + return true + } + + if (kind === "edit") { + emitEdit(state) + return true + } + + if (kind === "patch") { + emitPatch(state) + return true + } + + if (kind === "task") { + emitTask(state) + return true + } + + if (kind === "todo") { + emitTodo(state) + return true + } + + if (kind === "question") { + emitQuestionTool(state) + return true + } + + if (kind === "error") { + emitError(state, body || "demo error event") + return true + } + + if (kind === "mix") { + await emitText(state, SAMPLE_MARKDOWN, signal) + await wait(50, signal) + await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal) + await wait(50, signal) + await emitBash(state, signal) + emitWrite(state) + emitEdit(state) + emitPatch(state) + emitTask(state) + emitTodo(state) + emitQuestionTool(state) + emitError(state, "demo mixed scenario error") + return true + } + + return false +} + +function intro(state: State): void { + note( + state.footer, + [ + "Demo slash commands enabled for interactive mode.", + `- /permission [kind] (${PERMISSIONS.join(", ")})`, + `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /fmt (${KINDS.join(", ")})`, + "Examples:", + "- /permission bash", + "- /question custom", + "- /fmt markdown", + "- /fmt table", + "- /fmt text your custom text", + ].join("\n"), + ) +} + +export function createRunDemo(input: Input) { + const state: State = { + id: input.sessionID, + thinking: input.thinking, + data: createSessionData(), + footer: input.footer, + limits: input.limits, + msg: 0, + part: 0, + call: 0, + perm: 0, + ask: 0, + perms: new Map(), + asks: new Map(), + } + + const start = async (): Promise => { + intro(state) + if (input.mode === "on") { + return + } + + if (input.mode === "permission") { + emitPermission(state, "edit") + return + } + + if (input.mode === "question") { + emitQuestion(state, "multi") + return + } + + if (input.mode === "mix") { + await emitFmt(state, "mix", "") + return + } + + if (input.mode === "text") { + await emitFmt(state, "text", input.text ?? SAMPLE_MARKDOWN) + } + } + + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + clearSubagent(state.footer) + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = permissionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = questionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + return true + } + + emitQuestion(state, kind) + return true + } + + if (cmd === "/fmt") { + const kind = (list[1] || "").toLowerCase() + const body = list.slice(2).join(" ") + if (!kind) { + note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) + return true + } + + const ok = await emitFmt(state, kind, body, signal) + if (ok) { + return true + } + + note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`) + return true + } + + return false + } + + const permission = (input: PermissionReply): boolean => { + const item = state.perms.get(input.requestID) + if (!item || !input.reply) { + return false + } + + state.perms.delete(input.requestID) + const event = { + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } satisfies Event + feed(state, event) + + if (input.reply === "reject") { + failTool(state, item.ref, input.message || "permission rejected") + return true + } + + doneTool(state, item.ref, item.done) + return true + } + + const questionReply = (input: QuestionReply): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask || !input.answers) { + return false + } + + state.asks.delete(input.requestID) + const event = { + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } satisfies Event + feed(state, event) + doneTool(state, ask.ref, { + title: "question", + output: "", + metadata: { + answers: input.answers, + }, + }) + return true + } + + const questionReject = (input: QuestionReject): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.rejected", + properties: { + sessionID: state.id, + requestID: input.requestID, + }, + } as Event) + failTool(state, ask.ref, "question rejected") + return true + } + + return { + start, + prompt, + permission, + questionReply, + questionReject, + } +} diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 0000000000..741198a499 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,183 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking:" + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 0000000000..006b33b5ae --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -0,0 +1,487 @@ +// Permission UI body for the direct-mode footer. +// +// Renders inside the footer when the reducer pushes a FooterView of type +// "permission". Uses a three-stage state machine (permission.shared.ts): +// +// permission → shows the request with Allow once / Always / Reject buttons +// always → confirmation step before granting permanent access +// reject → text field for the rejection message +// +// Keyboard: left/right to select, enter to confirm, esc to reject. +// The diff view (when available) uses the same diff component as scrollback +// tool snapshots. +/** @jsxImportSource @opentui/solid */ +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { + createPermissionBodyState, + permissionAlwaysLines, + permissionCancel, + permissionEscape, + permissionHover, + permissionInfo, + permissionLabel, + permissionOptions, + permissionReject, + permissionRun, + permissionShift, + type PermissionOption, +} from "./permission.shared" +import { toolDiffView, toolFiletype } from "./tool" +import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" +import type { PermissionReply, RunDiffStyle } from "./types" + +type RejectArea = { + isDestroyed: boolean + plainText: string + cursorOffset: number + setText(text: string): void + focus(): void +} + +function buttons( + list: PermissionOption[], + selected: PermissionOption, + theme: RunFooterTheme, + disabled: boolean, + onHover: (option: PermissionOption) => void, + onSelect: (option: PermissionOption) => void, +) { + return ( + + + {(option) => ( + { + if (!disabled) onHover(option) + }} + onMouseUp={() => { + if (!disabled) onSelect(option) + }} + > + {permissionLabel(option)} + + )} + + + ) +} + +function RejectField(props: { + theme: RunFooterTheme + text: string + disabled: boolean + onChange: (text: string) => void + onConfirm: () => void + onCancel: () => void +}) { + let area: RejectArea | undefined + + createEffect(() => { + if (!area || area.isDestroyed) { + return + } + + if (area.plainText !== props.text) { + area.setText(props.text) + area.cursorOffset = props.text.length + } + + queueMicrotask(() => { + if (!area || area.isDestroyed || props.disabled) { + return + } + area.focus() + }) + }) + + return ( +